migrate · diff

git:20260915.de884e2 to git:20261012.4b1b6a8

222 added, 38 removed. Audit B to B.

---
name: migrate
description: 'Upgrade PROJECT_STATE.json schema to the current version, deliver artefact templates a project never got, and switch experimental modes on or off by command instead of hand-editing state JSON. Use when the PM says "migrate project", "upgrade schema", "enable mode" / "disable mode", "turn on changeSpec", "turn on designCorpus", "мигрируй проект", "обнови схему", "включи режим", "выключи режим". Dry-run by default: nothing is written without --apply.'
---
# /polisade:migrate — Schema Migration
Обновляет PROJECT_STATE.json и knowledge.json до текущей версии схемы. Добавляет недостающие поля, создаёт artifactIndex, устанавливает schemaVersion. Также добавляет `testing.strategy` в knowledge.json если отсутствует.
- VCS bootstrap: если `settings.vcsProvider` отсутствует — добавляет `"github"`. Если PM вручную переключил провайдер на `bitbucket-server` — создаёт `.env.example` (reference) и `.env` (stub для заполнения токенов) из plugin templates, добавляет некомментированную `.env` в `.gitignore`. Заполненный `.env` не перезаписывается (идемпотентность). ⚠️ Под GigaCode Filesystem Guard сам скрипт миграции может не запуститься (install-dir read-protected, #127) — тогда `.env`/`.env.example` автоматически не появятся; PM создаёт `.env` вручную из `.env.example` (`cp .env.example .env`) и заполняет токены (#131).
+ VCS bootstrap: если `settings.vcsProvider` отсутствует — добавляет `"github"`. Если PM вручную переключил провайдер на `bitbucket-server` — создаёт `.env.example` (reference) и `.env` (stub для заполнения токенов) из plugin templates, добавляет некомментированную `.env` в `.gitignore`. Заполненный `.env` не перезаписывается (идемпотентность). ⚠️ Под GigaCode Filesystem Guard сам скрипт миграции может не запуститься (install-dir read-protected) — тогда `.env`/`.env.example` автоматически не появятся; PM создаёт `.env` вручную из `.env.example` (`cp .env.example .env`) и заполняет токены.
## Использование
```
/polisade:migrate # Dry-run — показать что изменится
/polisade:migrate --apply # Показать diff и применить после подтверждения
/polisade:migrate --enable=changeSpec --apply # Включить режим
/polisade:migrate --disable=designCorpus --apply # Выключить (переключатель)
+ /polisade:migrate --show-template-diff # Чем шаблоны проекта разошлись с каноном
+ /polisade:migrate --adopt-templates=all --apply # Принять канон шаблонов
```
- Экспериментальные режимы включаются **командой, а не правкой JSON руками**
- (issue #339). `--enable=<флаг>` / `--disable=<флаг>` (через запятую —
+ Экспериментальные режимы включаются **командой, а не правкой JSON руками**.
+ `--enable=<флаг>` / `--disable=<флаг>` (через запятую —
несколько) пишут `settings.experimental` тем же путём, что и любая другая
миграция: значит попадают в план dry-run, в `stage_paths` и в коммит.
Несуществующий флаг и опечатка в самой опции — **отказ** со списком
доступных, а не тихое умолчание. Повторное включение — no-op.
## Алгоритм
1. Определить корень проекта. Затем **preflight интерпретатора** —
`${POLISADE_PYTHON:-python3} --version`: печатается версия, значит рельса
рабочая и остальные шаги пойдут по ней.
<!-- polisade:python-stop CAPSULE BEGIN -->
> ⛔ **Интерпретатор не стартовал — STOP, а не поиск.** Python-скрипты плагина зовутся ТОЛЬКО как `${POLISADE_PYTHON:-python3} …`. Если упал сам вызов, ещё до скрипта (`command not found`, `не является внутренней или внешней командой`, `No such file or directory`) — **не ищи интерпретатор по машине**: ни `where`/`which`, ни обход `C:\Python*`, ни `py -3` наугад, ни чужой venv. Найденный так путь недетерминирован — в следующий раз он будет другим.
> Процитируй ошибку дословно и попроси PM выставить `POLISADE_PYTHON`: команда на PATH (`py`, `py -3`, `python`) или абсолютный путь **без пробелов**. Подстановка голая, как у `POLISADE_PLUGIN_ROOT`: несколько слов разойдутся в argv — для лаунчера это верно, а путь с пробелами так разорвётся и не найдётся (на такой машине нужен шим на PATH). До ответа PM шаг не продолжается.
<!-- polisade:python-stop CAPSULE END -->
2. **Самопроверка скрипта — ОБЯЗАТЕЛЬНА перед dry-run и повторно перед
- `--apply`** (issue #182). Мигратор диагностирует сам себя: печатает версию,
+ `--apply`**. Мигратор диагностирует сам себя: печатает версию,
sha256 собственных байтов, число строк и структурные пробы. Вывод целиком
цитируется в отчёт PM. Последняя строка не `self-check: ok` (или exit ≠ 0) —
**STOP**: запущенный файл не канонический мигратор, `--apply` не выполняется.
Обратное неверно: `ok` — не доказательство каноничности. Проверка
структурная (наличие функций и форма возврата), она не сверяет sha256 ни с
каким доверенным источником и не видит порчу в соседних модулях. `ok`
означает «эта копия не обрублена и не сменила форму», а не «эта копия — та
самая». Не заявляй PM большего.
<!-- polisade:exec-denied CAPSULE BEGIN -->
- > ⛔ **Вызов скрипта отклонён или не запустился** (`Command references protected path` / `Install directory is read-protected` / `Filesystem Guard` / `the tool's default permission is 'deny'`, отказ песочницы, ненулевой exit без вывода) — **STOP**.
+ > ⛔ **Если вызов скрипта на этом шаге ты СДЕЛАЛ, и он отклонён или не запустился** (`Command references protected path` / `Install directory is read-protected` / `Filesystem Guard` / `the tool's default permission is 'deny'`, отказ песочницы, ненулевой exit без вывода) — **STOP**. Вызова не было — отказа нет: сначала выполни команду; этот блок описывает её вывод и сам ответом не служит.
> Процитируй отказ дословно. НЕ пересказывай по исходнику, что скрипт «сделал бы»; НЕ собирай dry-run вручную; НЕ переходи к apply/push/pr-create.
> НЕ транскрибируй скрипт (прочитать → записать копию в `/tmp` или в проект → запустить копию): копия не байт-идентична — уезжают классы символов в regex, форма возврата функций, пропадают целые функции — и молча меняется набор применённых изменений.
- > Отказ инструмента — это отказ, а не результат. Доложи PM дословный текст отказа и сошлись на #127 (доставка скриптов в проект).
+ > Отказ инструмента — это отказ, а не результат. Доложи PM дословный текст отказа и назови следствие: без рабочего вызова скрипта этот шаг выполнить нечем. Причину не придумывай — её называет только сам текст отказа.
<!-- polisade:exec-denied CAPSULE END -->
```bash
${POLISADE_PYTHON:-python3} {plugin_root}/scripts/polisade_migrate.py --self-check
```
3. Запустить миграцию в режиме dry-run:
```bash
${POLISADE_PYTHON:-python3} {plugin_root}/scripts/polisade_migrate.py {project_root}
```
- 4. Распарсить JSON-ответ, показать список миграций пользователю. В том же
- сообщении процитировать `sha256` и `lines` из шага 2 — PM подтверждает
- `--apply` для конкретного файла, а не для «скрипта вообще».
+ 4. **Твой ответ PM начинается с итога прогона и пути к полному отчёту.**
+ Блок `ОТЧЁТ ДЛЯ PM` (между линейками `═`) скрипт напечатал сам и сам же
+ сохранил файлом проекта; под блоком стоит готовая строка
+ `Полный отчёт для PM сохранён в файл: <путь>` — она же поле
+ `pm_report.line`. **Назови PM этот путь как есть**: полный блок лежит там
+ байт в байт. Статус и числа — ТОЛЬКО из JSON (`status`, `summary`); сводку
+ приводи как есть, не пересчитывай. Замерено: пересказанные числа
+ разъезжались (25 миграций становились «15», потом «21»), а по ним PM решает
+ про `--apply`. `pm_report.status` не `written` — скажи это прямо: тогда
+ блок выше единственная копия отчёта.
- ⛔ **Числа НЕ считай — печатай поле `summary` ДОСЛОВНО** (issue #354).
- Скрипт складывает их сам; строка готова к вставке. Замерено на живом
- прогоне ДВАЖДЫ: правила «бери число из JSON» оказалось мало — модель всё
- равно пересказывала план корзинами, и 25 миграций стали «15 штук», потом
- «21», а 11 шаблонов — «14», потом «9», причём тут же перечислялись все
- одиннадцать. По этим числам PM решает про `--apply`.
- **Список показывай как есть, по строке на миграцию** — сворачивать в
- придуманные группы нельзя. Никаких чисел, которых нет в ответе скрипта.
+ Дальше в том же сообщении: **список миграций как есть, по строке на
+ миграцию** (сворачивать в придуманные группы нельзя), плюс `sha256` и
+ `lines` из шага 2 — PM подтверждает `--apply` для конкретного файла, а не
+ для «скрипта вообще». Никаких чисел, которых нет в ответе скрипта.
+
+ Если `status: invalid_derived_lists` (rc=2) — **STOP**. Покажи `issues[]`
+ с путями и типами и `action` дословно. Ни schemaVersion, ни другие поля
+ мигратор не записал. Не превращай объекты в строки по одному `id`:
+ дополнительные поля надо сохранить в выбранной оператором долговечной
+ записи до ручной правки и повторного запуска. `--apply` сейчас запрещён.
+
+ `pm_questions` вида `artifact-status-decision` — план ручного разбора,
+ а не разрешение на переписывание. Прочитай артефакт и источник решения:
+ прежние `cancelled`/`not_actual` не становятся `done`, а ADR
+ `not_applicable` не становится `superseded` без нового ADR в
+ `superseded_by`. Для неизвестного статуса не угадывай эквивалент.
5. Если пользователь подтверждает — повторить `--self-check` (тот же sha256,
снова `self-check: ok`) и применить:
<!-- polisade:exec-denied CAPSULE BEGIN -->
- > ⛔ **Вызов скрипта отклонён или не запустился** (`Command references protected path` / `Install directory is read-protected` / `Filesystem Guard` / `the tool's default permission is 'deny'`, отказ песочницы, ненулевой exit без вывода) — **STOP**.
+ > ⛔ **Если вызов скрипта на этом шаге ты СДЕЛАЛ, и он отклонён или не запустился** (`Command references protected path` / `Install directory is read-protected` / `Filesystem Guard` / `the tool's default permission is 'deny'`, отказ песочницы, ненулевой exit без вывода) — **STOP**. Вызова не было — отказа нет: сначала выполни команду; этот блок описывает её вывод и сам ответом не служит.
> Процитируй отказ дословно. НЕ пересказывай по исходнику, что скрипт «сделал бы»; НЕ собирай dry-run вручную; НЕ переходи к apply/push/pr-create.
> НЕ транскрибируй скрипт (прочитать → записать копию в `/tmp` или в проект → запустить копию): копия не байт-идентична — уезжают классы символов в regex, форма возврата функций, пропадают целые функции — и молча меняется набор применённых изменений.
- > Отказ инструмента — это отказ, а не результат. Доложи PM дословный текст отказа и сошлись на #127 (доставка скриптов в проект).
+ > Отказ инструмента — это отказ, а не результат. Доложи PM дословный текст отказа и назови следствие: без рабочего вызова скрипта этот шаг выполнить нечем. Причину не придумывай — её называет только сам текст отказа.
<!-- polisade:exec-denied CAPSULE END -->
+ <!-- polisade:pm-gate CAPSULE BEGIN -->
+ > ⛔ **В ответе `pm_gate.status: blocked` и код возврата 3 — STOP до коммита.** Это ОТКАЗ гейта, а не падение инструмента: работа выполнена, файлы записаны и лежат локально, но коммит, push и PR не выполняются, пока к PM есть неотвеченные вопросы.
+ > Напечатай блок отказа из ответа ДОСЛОВНО: в нём уже перечислены сами вопросы и стоят две готовые команды с подставленными путями. Не пересказывай, вопросы не сворачивай в группы и не решай за PM, какой из них «неважный».
+ > Дальше выбирает PM, и путей ровно два: **ответить** — исправить условие в артефакте и повторить проверку названной командой; **отложить** — `polisade_pm_defer.py` с обязательной причиной. Отсрочку ставит человек: сам её не записывай, состояние руками не правь и `--intermediate-step` в эту команду не добавляй.
+ > ⛔ **Статус прогона `blocked_pm_decision_unconfirmed` и код 3 — тоже STOP, и разговор другой.** Вопрос закрылся не ответом, а правкой самого предмета (`status`/`status_reason` артефакта, расходящийся шаблон, флаг режима), и автор решения не назван. Не выставляй статус артефакта сам и не сочиняй причину: отсутствие ответа PM это не «отменено» и не «неактуально». Путей два, и оба называет сам отказ: **вернуть** предмет в прежнее состояние — тогда вопрос задастся снова; **признать** — это делает человек командой `polisade_pm_defer.py --confirm` с обязательной причиной.
+ > ⛔ **В отказе назван исход артефакта, не признанный PM (`cancelled` / `not_actual` / `not_applicable`) — STOP, и выбирает его только человек.** Это решение, а не факт о коде, и барьер смотрит на состояние: неважно, когда исход появился и кто его вписал. Сам такой статус не ставь, причину отмены не сочиняй и команду признания за человека не запускай. Путей два, и оба называет сам отказ: **исправить** статус, если он записан неверно; **признать** — `polisade_pm_defer.py --confirm` с обязательной причиной, от человека.
+ > 🔎 **Каждая запись решения PM помечается контуром, из которого она сделана.** Если команду отсрочки или признания вызовешь ты, запись выйдет с пометкой «сделано изнутри агентской сессии», и эта пометка встанет отдельной строкой в отчёте для PM и в теле PR. Снимать её — в том числе запуском команды с очищенным окружением — запрещено: она не мешает работе, она делает видимым, чьей рукой решение записано.
+ <!-- polisade:pm-gate CAPSULE END -->
+
```bash
- ${POLISADE_PYTHON:-python3} {plugin_root}/scripts/polisade_migrate.py {project_root} --apply --yes
+ ${POLISADE_PYTHON:-python3} {plugin_root}/scripts/polisade_migrate.py {project_root} --apply --yes \
+ --pr-body={project_root}/.polisade/tmp/pr-body.md
```
+ `--pr-body` обязателен, если дальше будет PR: тело PR собирает ИНСТРУМЕНТ из
+ отчёта ЭТОГО прогона. Собрать его потом нельзя — повторный
+ прогон уже `up_to_date` и напечатает «Миграций: 0». В ответе появляется поле
+ `pr_body` со статусом `written` / `failed`; `failed` это не тишина, а факт,
+ который придётся назвать в теле PR.
+
+ 6. **Доложить итог этого прогона и путь к его отчёту** — как на шаге 4:
+ `applied_count` и `summary` из JSON, путь — из `pm_report.line`. Маршрут
+ (`next_steps`) это следующий шаг PM, а не украшение отчёта: режим, который
+ он только что включил, сам ничего не делает. Маршрут лежит и в файле
+ отчёта, и в состоянии (`.state/pm-reports/migrate.latest.json`), его
+ читает `/polisade:doctor`. Маршрута в JSON нет — значит режим выключен и
+ шага нет; выдумывать его нельзя.
+
## Формат вывода
`polisade_migrate.py` всегда печатает **один** JSON-документ на stdout
(контракт OPS-108 — `json.loads(stdout)` обязан проходить). PM-friendly
сообщения и подтверждение интерактивного prompt'а уходят на stderr.
Полная таблица контрактов — `docs/config-reference.md` § Script JSON
output contracts.
+ ### Блок `ОТЧЁТ ДЛЯ PM`
+
+ На stderr (и тем же значением в поле `pm_block`) скрипт печатает готовый
+ отчёт PM между линейками `═`: статус прогона, сводку и пронумерованный
+ маршрут. Строки внутри блока — те же байты, что в полях `summary` и
+ `next_steps` того же прогона; блок собран из них, а не написан рядом вторым
+ текстом. Маршрута в блоке нет, когда режима живого корпуса нет: тогда его нет
+ и в ответе PM.
+
+ Тот же блок, байт в байт, скрипт кладёт файлом в `.state/pm-reports/`
+ (новый файл на каждый прогон, в git не попадает) и печатает под блоком
+ готовую строку с путём. В JSON это поле `pm_report`: `status`
+ (`written` / `failed` / `skipped`), `path`, `sha256` и `line` — ту самую
+ строку. Дословного копирования блока от тебя больше не ждут: PM получает
+ итог из JSON и путь к файлу, а полный отчёт открывает там.
+
### Если схема актуальна
```json
{
"status": "up_to_date",
"schemaVersion": 7,
"polisadeVersion": "3.0.0",
"touched_paths": [],
"stage_paths": []
}
```
### Если нужна миграция (dry-run)
```json
{
"status": "migration_needed",
"current_schema": 3,
"target_schema": 6,
"migrations": [
"Update schemaVersion: 3 → 7",
"Add settings.debt.autoCreateTask: true (preserve legacy auto-TASK behavior)"
],
"touched_paths": [".state/PROJECT_STATE.json"],
"stage_paths": [".state/PROJECT_STATE.json"],
"dry_run": true
}
```
### После `--apply --yes`
```json
{
"status": "applied",
"schemaVersion": 7,
"applied_count": 2,
"migrations": ["Update schemaVersion: 3 → 7", "..."],
"touched_paths": [".state/PROJECT_STATE.json"],
"stage_paths": [".state/PROJECT_STATE.json"]
}
```
`touched_paths` — всё, что миграция тронула (для информации и для
diff-сверки с `git status --porcelain`).
- `experimental_modes` (issue #339) — состояние каждого режима: значение,
+
+ `experimental_modes` — состояние каждого режима: значение,
инертность, что цикл будет делать и **чего режиму не хватает в проекте**
(`requires[].status`: `ok` / `will-arrive` — привезёт этот же прогон /
`missing`). Показать PM целиком: включённый режим, которому не хватает
шаблона, — это не «всё готово» (класс F1). В dry-run печатается ТЕКУЩЕЕ
состояние; что изменится, говорит список `migrations`.
+ `requires` называет не только файлы: элемент с ключом `command` и статусом
+ `unavailable` означает, что не хватает **самой команды** — её нет в сборке под
+ эту цель (`experimental_modes.<флаг>.target`), и режим с `available: false`
+ включать нечему. Процитируй `reason` дословно; не переписывай его в «нужно
+ что-то доустановить». Явный `--enable` такого режима скрипт отклоняет
+ (`status: refused`, rc=2) — это отказ, а не сбой.
+
+ `next_steps` — **готовые строки следующего шага**, они же строки маршрута
+ внутри блока `ОТЧЁТ ДЛЯ PM`. Это маршрут «включил → построил → перевёл силосы
+ → сверил» для PM, который только что включил живой корпус ЭТОЙ же командой.
+ Ключевое утверждение маршрута — **корпус не строится сам**; если ты
+ пересказываешь его своими словами, ты пересказываешь его неверно (замерено —
+ из строк устойчиво исчезала именно объясняющая половина). Список пуст — значит
+ режим выключен и маршрута нет; тогда не выдумывай его.
+
+ ⛔ **`design_silos` — три формы, не две**. Пустой `design_silos`
+ сам по себе НЕ означает «силосов нет»: он заполняется только под
+ `--migrate-design`. Что говорить PM, решает `design_silos_status.status`:
+
+ | `status` | что это значит | что печатать |
+ |---|---|---|
+ | `not-analyzed` | силосы на диске ЕСТЬ (`found`, `silos`), но миграция их интента не запрашивалась | «найдено пакетов: N (перечислить); их интент НЕ анализировался — нужен прогон с `--migrate-design`» |
+ | `none` | пакетов DESIGN-NNN в проекте нет | «силосов нет — переводить на корпус нечего» |
+ | `analyzed` | прогон был с `--migrate-design` | разбор по каждому пакету — из `design_silos` |
+
+ Никогда не докладывай «силосов, по-видимому, нет» по пустому списку: это
+ ровно та ошибка, ради которой заведён `design_silos_status`.
+
`stage_paths` — subset для `git add`: исключает пути, которые после
миграции попали под `.gitignore` (например `.env` при bitbucket bootstrap
оказывается в `touched_paths`, но НЕ в `stage_paths`, потому что та же
миграция добавила `.env` в `.gitignore` — попытка `git add .env` дала
бы rc=1).
+ `pr_body` — появляется только при `--pr-body=<путь.md>`:
+ `{"path": ..., "status": "written"}`, `{"status": "failed", "detail": ...}` либо
+ `{"status": "skipped", "detail": ...}` — на любом `--apply`, который не был
+ подтверждён (и при ответе «нет», и при отказе за отсутствием человека).
+ Указанная опция, не давшая файла, обязана быть НАЗВАНА, а не исчезнуть из ответа.
+ Тело PR собирает ИНСТРУМЕНТ из этого же отчёта (сводка, миграции, пути,
+ вопросы PM, режимы), поэтому в PR попадает то, что раньше оставалось в
+ консоли. `failed` называется в теле PR прямым текстом.
+
+ ### Дифф шаблонов (`--show-template-diff`)
+
+ Отдельный статус `template_diff` и отдельный документ: `canonical_source`
+ (откуда прочитан канон), `templates[]` с полями `name`, `state`
+ (`diverged` / `same` / `absent` / `symlink` / `unreadable`), `added`,
+ `removed`, `diff`, плюс `diverged[]` и `summary`. Ничего не пишет и не
+ планирует; `touched_paths` и `stage_paths` пусты. Дифф печатай как есть — его
+ собрал инструмент.
+
+ ### Если derived list содержит неверный тип (rc=2)
+
+ `status: invalid_derived_lists` содержит `issues[]` с путями вида
+ `inReview[0]`, ожидаемым и фактическим типом, а также `action` с порядком
+ ручного восстановления. `touched_paths` и `stage_paths` пусты. Ни dry-run,
+ ни apply не изменяют файлы; миграция не сообщает `up_to_date` или `applied`.
+
## Важно
- - **По умолчанию dry-run** — не записывает ничего без `--apply`
+ - **По умолчанию dry-run** — проект не меняется без `--apply`; пишется только
+ файл отчёта для PM в `.state/pm-reports/` (локальный, в git не попадает)
- **Никогда не трогает `artifacts`** — только создаёт новый `artifactIndex`
- - **Доносит шаблоны артефактов** (`docs/templates/*`, issue #341). Скиллы
+ - **Доносит шаблоны артефактов** (`docs/templates/*`). Скиллы
читают шаблон ИЗ ПРОЕКТА, а не из плагина, поэтому проект, приехавший со
старой версии, упирался в отсутствующий файл — `change-spec-template.md`
после включения `changeSpec` тому пример. Правило троичное и несимметричное:
шаблона нет — добавляется; совпал (с точностью до концов строк) — тишина;
**отличается — вопрос PM, никогда не перезапись**: правка шаблона командой
это источник, а не производная. Что именно разошлось, видно в `pm_questions`
(`docs-template-diverged:<имя>`); решение за человеком.
+ - **У вопроса о расхождении есть выход**. Он называет ТРИ вещи,
+ без которых решение принять нельзя: сколько строк добавил бы и убрал бы
+ переход на канон, по какому адресу канон прочитан ИМЕННО в этом прогоне
+ (вендорённый набор проекта разрешается первым — под Filesystem Guard каталог
+ установки нечитаем), и команду, которой канон принимается. Дифф печатает
+ ИНСТРУМЕНТ: `--show-template-diff` (все разошедшиеся) либо
+ `--show-template-diff=<имя.md>`; это диагностика, она ничего не пишет.
+ Принятие — `--adopt-templates=<имя.md,…>` или `=all`: **обычная миграция**,
+ то есть план dry-run, `stage_paths`, коммит. Без флага поведение прежнее,
+ молчаливой перезаписи нет; повторный прогон после принятия — no-op.
+ Несуществующее имя шаблона — **отказ** (rc=2) со списком доступных, а не
+ молчаливый пропуск; неузнанное написание опции про шаблоны — тоже отказ.
+ До этого PM получал в корп-прогоне девять одинаковых абзацев «решение за
+ человеком» и ни одной зацепки, а агент дальше искал канон обходом каталога
+ установки и копировал файлы руками мимо плана и коммита.
- Безопасно запускать повторно — идемпотентная миграция
- После миграции `/polisade:doctor` должен показывать pass для state_schema
## После применения — закоммить и открыть PR
После `/polisade:migrate --apply` рабочее дерево обычно содержит изменения
(`.state/PROJECT_STATE.json`, иногда `.gitignore`, `.env.example`,
<!-- polisade:claude-only BEGIN -->`.claude/settings.json`, <!-- polisade:claude-only END -->`tasks/TASK-*.md` под OPS-026, и т. п.). PM в
- корп-сессии (issue #108) после этого попросит «закоммить и сделай pr» —
+ корп-сессии после этого попросит «закоммить и сделай pr» —
агент должен пройти ровно по этому рецепту, без импровизации.
**Контракт**: ни одного `$(...)`, бэктиков (command substitution в Bash) или
`<(...)` / `>(...)` в шелл-командах ниже. Корп-шелл (GigaCode CLI / codex
sandbox) режет их с сообщением «Command substitution using $(), \`\`, <(),
or >() is not allowed for security reasons», и весь рецепт обрывается.
+ <!-- polisade:pm-gate CAPSULE BEGIN -->
+ > ⛔ **В ответе `pm_gate.status: blocked` и код возврата 3 — STOP до коммита.** Это ОТКАЗ гейта, а не падение инструмента: работа выполнена, файлы записаны и лежат локально, но коммит, push и PR не выполняются, пока к PM есть неотвеченные вопросы.
+ > Напечатай блок отказа из ответа ДОСЛОВНО: в нём уже перечислены сами вопросы и стоят две готовые команды с подставленными путями. Не пересказывай, вопросы не сворачивай в группы и не решай за PM, какой из них «неважный».
+ > Дальше выбирает PM, и путей ровно два: **ответить** — исправить условие в артефакте и повторить проверку названной командой; **отложить** — `polisade_pm_defer.py` с обязательной причиной. Отсрочку ставит человек: сам её не записывай, состояние руками не правь и `--intermediate-step` в эту команду не добавляй.
+ > ⛔ **Статус прогона `blocked_pm_decision_unconfirmed` и код 3 — тоже STOP, и разговор другой.** Вопрос закрылся не ответом, а правкой самого предмета (`status`/`status_reason` артефакта, расходящийся шаблон, флаг режима), и автор решения не назван. Не выставляй статус артефакта сам и не сочиняй причину: отсутствие ответа PM это не «отменено» и не «неактуально». Путей два, и оба называет сам отказ: **вернуть** предмет в прежнее состояние — тогда вопрос задастся снова; **признать** — это делает человек командой `polisade_pm_defer.py --confirm` с обязательной причиной.
+ > ⛔ **В отказе назван исход артефакта, не признанный PM (`cancelled` / `not_actual` / `not_applicable`) — STOP, и выбирает его только человек.** Это решение, а не факт о коде, и барьер смотрит на состояние: неважно, когда исход появился и кто его вписал. Сам такой статус не ставь, причину отмены не сочиняй и команду признания за человека не запускай. Путей два, и оба называет сам отказ: **исправить** статус, если он записан неверно; **признать** — `polisade_pm_defer.py --confirm` с обязательной причиной, от человека.
+ > 🔎 **Каждая запись решения PM помечается контуром, из которого она сделана.** Если команду отсрочки или признания вызовешь ты, запись выйдет с пометкой «сделано изнутри агентской сессии», и эта пометка встанет отдельной строкой в отчёте для PM и в теле PR. Снимать её — в том числе запуском команды с очищенным окружением — запрещено: она не мешает работе, она делает видимым, чьей рукой решение записано.
+ <!-- polisade:pm-gate CAPSULE END -->
+
<!-- polisade:push-stop CAPSULE BEGIN -->
- > ⛔ **`polisade_vcs.py` недоступен** (`Command references protected path` / `Install directory is read-protected` / `Filesystem Guard`) — **STOP до push**: ни `git-push`, ни `pr-create`, ни `pr-merge`, ни `pr-comment` не выполняются.
- > Bare `git push` запрещён (инвариант #10 / OPS-028); самодельные REST/curl-вызовы к Bitbucket/GitHub запрещены; helper не транскрибируется в `/tmp`.
- > Доложи PM дословно: «push пропущен — `polisade_vcs.py` заблокирован sandbox (#127); коммит локально в ветке `<имя>`; pr-create не выполнялся» — и заверши рецепт на этом.
+ > ⛔ **Если вызов `polisade_vcs.py` на этом шаге ты СДЕЛАЛ, и он отклонён** (`Command references protected path` / `Install directory is read-protected` / `Filesystem Guard`) — **STOP до push**: ни `git-push`, ни `pr-create`, ни `pr-merge`, ни `pr-comment` больше не выполняются. Вызова не было — отказа нет: сначала выполни команду; этот блок описывает её вывод и сам ответом не служит.
+ > Bare `git push` запрещён (push обязан проверяться сравнением remote SHA — OPS-028); самодельные REST/curl-вызовы к Bitbucket/GitHub запрещены; helper не транскрибируется в `/tmp`.
+ > Доложи PM дословный текст отказа из вывода этого вызова, ветку с локальным коммитом и то, что pr-create не выполнялся, — и заверши рецепт на этом. Готовой фразы отказа здесь нет: её даёт только вывод вызова.
<!-- polisade:push-stop CAPSULE END -->
```bash
# 0. Рабочий каталог. Caller (агент) должен заранее cd в проект; рецепт
# использует статический fallback "." (ни command substitution, ни pwd).
WORK_DIR="${POLISADE_WORK_DIR:-.}"
# ⛔ Любая команда рецепта отклонена (deny, песочница, ненулевой exit) — СТОП и
# доклад PM дословно. Замену НЕ подбирать: каталог вместо перечисления путей
- # расширяет коммит молча (issue #338).
+ # расширяет коммит молча.
# 1. Источник списка путей — поле stage_paths из последнего apply-JSON:
# {"status":"applied","stage_paths":[".state/PROJECT_STATE.json", ...]}
# НЕ touched_paths: stage_paths уже исключает gitignored (например .env
# при bitbucket bootstrap). Stage по touched_paths упал бы на rc=1
# «paths are ignored», после чего weak-model агент попытался бы обойти
# запрет принудительным флагом — это утечка токенов из .env.
# Если в одной сессии PM запускал И /polisade:migrate --apply, И /polisade:sync
- # --apply (типичный сценарий issue #108) — берём union stage_paths из
+ # --apply (типичный корп-сценарий) — берём union stage_paths из
# обоих JSON-ответов. Safety-net: git status --porcelain -uall (минус
# игноры) должен совпадать с union'ом; иначе СТОП и вопрос PM. Флаг -uall
# обязателен: без него git схлопывает новый каталог в одну запись, и
- # сверка расходится на штатной миграции (issue #341).
+ # сверка расходится на штатной миграции.
# 2. Имя ветки. Из контекста скилла:
# migrate в одиночку → polisade-migrate-schema-<N> (N = target_schema)
# sync в одиночку → polisade-sync-<YYYY-MM-DD>
# migrate + sync в одной сессии → polisade-housekeeping-<YYYY-MM-DD>
#
# 2a. Имя детерминированное, значит вторая попытка даёт ТО ЖЕ имя: проверь
# занятость ДО создания ветки, иначе узнаешь на push — после коммита,
- # а форс-пуш запрещён (issue #333).
+ # а форс-пуш запрещён.
git ls-remote --heads origin <branch>
# Пусто → имя свободно. Не пусто → НЕ форси: возьми суффикс <branch>-2
# (и проверь снова) либо остановись и спроси PM, что с занятой веткой.
git switch -c <branch>
- # 3. Стейджим ровно stage_paths — НЕ git add . и НЕ каталогом (issue #338).
+ # 3. Стейджим ровно stage_paths — НЕ git add . и НЕ каталогом.
# Список идёт ФАЙЛОМ, а не перечислением в argv: 34 пути, перепечатанные
# руками, дали опечатку и потерянный шаг. Нужен git 2.25+.
# Запиши в .polisade/tmp/stage.txt по одному пути из stage_paths на строку.
mkdir -p .polisade/tmp
git add --pathspec-from-file=.polisade/tmp/stage.txt
# 4. Коммит с детерминированным сообщением.
git commit -m "<skill>: <scoped summary>"
- # 5. Push — ОБЯЗАТЕЛЬНО через helper (инвариант #10 / OPS-028 / issues
- # #75 / #97). Bare git push в корпоративном окружении либо обходит
+ # 5. Push — ОБЯЗАТЕЛЬНО через helper (OPS-028: push подтверждается
+ # сравнением remote SHA). Bare git push в корпоративном окружении либо обходит
# проверку, либо даёт ложный FAIL на advisory remote-output.
${POLISADE_PYTHON:-python3} {plugin_root}/scripts/polisade_vcs.py git-push \
--branch <branch> --set-upstream \
--project-root "$WORK_DIR"
- # 6. Body PR — файлом, не command substitution. .polisade/tmp/ project-local
- # и в .gitignore (issue #57). НЕ /tmp — GigaCode CLI sandboxes /tmp.
- mkdir -p .polisade/tmp
- git log -1 --pretty=%B > .polisade/tmp/pr-body.md
+ ```
+ Дальше идут вызовы helper'а. Оба запрета — снова здесь, рядом с ними:
+
+ <!-- polisade:exec-denied CAPSULE BEGIN -->
+ > ⛔ **Если вызов скрипта на этом шаге ты СДЕЛАЛ, и он отклонён или не запустился** (`Command references protected path` / `Install directory is read-protected` / `Filesystem Guard` / `the tool's default permission is 'deny'`, отказ песочницы, ненулевой exit без вывода) — **STOP**. Вызова не было — отказа нет: сначала выполни команду; этот блок описывает её вывод и сам ответом не служит.
+ > Процитируй отказ дословно. НЕ пересказывай по исходнику, что скрипт «сделал бы»; НЕ собирай dry-run вручную; НЕ переходи к apply/push/pr-create.
+ > НЕ транскрибируй скрипт (прочитать → записать копию в `/tmp` или в проект → запустить копию): копия не байт-идентична — уезжают классы символов в regex, форма возврата функций, пропадают целые функции — и молча меняется набор применённых изменений.
+ > Отказ инструмента — это отказ, а не результат. Доложи PM дословный текст отказа и назови следствие: без рабочего вызова скрипта этот шаг выполнить нечем. Причину не придумывай — её называет только сам текст отказа.
+ <!-- polisade:exec-denied CAPSULE END -->
+
+ <!-- polisade:push-stop CAPSULE BEGIN -->
+ > ⛔ **Если вызов `polisade_vcs.py` на этом шаге ты СДЕЛАЛ, и он отклонён** (`Command references protected path` / `Install directory is read-protected` / `Filesystem Guard`) — **STOP до push**: ни `git-push`, ни `pr-create`, ни `pr-merge`, ни `pr-comment` больше не выполняются. Вызова не было — отказа нет: сначала выполни команду; этот блок описывает её вывод и сам ответом не служит.
+ > Bare `git push` запрещён (push обязан проверяться сравнением remote SHA — OPS-028); самодельные REST/curl-вызовы к Bitbucket/GitHub запрещены; helper не транскрибируется в `/tmp`.
+ > Доложи PM дословный текст отказа из вывода этого вызова, ветку с локальным коммитом и то, что pr-create не выполнялся, — и заверши рецепт на этом. Готовой фразы отказа здесь нет: её даёт только вывод вызова.
+ <!-- polisade:push-stop CAPSULE END -->
+
+ ```bash
+ # 5a. Состав PR — ДО публикации: стейдж пинит новый КОММИТ, а не то, что
+ # ветка унаследовала от локальной базы. Разбор — ниже, под рецептом.
+ ${POLISADE_PYTHON:-python3} {plugin_root}/scripts/polisade_vcs.py pr-scope \
+ --branch <branch> --base main \
+ --project-root "$WORK_DIR"
+ # ok: false (exit 2) — состав НЕ посчитан: СТОП, вопрос PM, не публикуй.
+ # Поле inherited не пусто — доложи PM пофамильно и жди решения; формулировка
+ # «в PR ничего лишнего» ЗАПРЕЩЕНА. Сам ничего не ребейзь. Поле summary печатай
+ # дословно: его собрал инструмент.
+
+ # 6. Body PR — его собрал ИНСТРУМЕНТ на шаге apply (опция --pr-body=...), а не
+ # сообщение последнего коммита: тело PR это отчёт прогона, а не строка
+ # заголовка. Сообщение коммита остаётся ЗАГОЛОВКОМ PR. .polisade/tmp/
+ # project-local и в .gitignore. НЕ /tmp — GigaCode CLI
+ # sandboxes /tmp.
+ ls -l .polisade/tmp/pr-body.md
+ # Файла нет ИЛИ в ответе apply было pr_body.status: "failed" — тело пишешь
+ # сам, и ПЕРВОЙ строкой называешь причину («отчёт инструмента не записан:
+ # <detail>»), а дальше цитируешь поле summary и незакрытые вопросы из
+ # ответа прогона. Молча подставить сообщение коммита ЗАПРЕЩЕНО: одна строка
+ # вместо отчёта и есть та тишина, ради которой опция заведена.
+
# 7. PR — через polisade_vcs.py, не /polisade:pr inline и не самодельный REST-вызов
# (anti-patterns в skills/pr/SKILL.md). Канонический скрипт —
# polisade_vcs.py, не polisade_pr.py: такого файла не существует.
${POLISADE_PYTHON:-python3} {plugin_root}/scripts/polisade_vcs.py pr-create \
--title "<skill>: <scoped summary>" \
--head <branch> --base main \
--body-file .polisade/tmp/pr-body.md \
--project-root "$WORK_DIR"
```
**Why этот рецепт жёсткий:**
- `polisade_vcs.py git-push` верифицирует push (exit-code + pattern-scan +
SHA), bare `git push` — нет.
- `--body-file` обходит ограничение корп-шелла на command substitution.
+ - `polisade_vcs.py pr-scope` считает состав PR **против удалённой базы**
+ (`git ls-remote`, а не локальный `origin/<base>`, который сам бывает
+ несвежим) и отдельно называет `inherited` — коммиты, достижимые ещё и с
+ локальной `refs/heads/<base>`, то есть бывшие там ДО этой ветки. Это
+ механическое определение «не моё»: оно не требует ни памяти о сессии, ни
+ догадки об авторстве. Зачем: `git switch -c` режет от того, чем оказалась
+ локальная база, и если она впереди удалённой, те коммиты въезжают в PR
+ молча — все остальные проверки рецепта смотрят в рабочее дерево, а не в
+ историю. Замерено на корп-миграции 17.09.2026: vendor-коммит сидел на
+ локальной `main` впереди `origin/main`, ветка нарезана поверх него, и оба
+ проверяющих назвали это «базой ветки, действий не требует». Скрипт только
+ читает: он никогда не ребейзит, не сбрасывает и ничего не выбрасывает —
+ включать унаследованный коммит в этот PR или изолировать, решает PM.
- Самодельный Python/curl в Bitbucket/GitHub REST API утекает токены из
`.env` мимо `polisade_vcs.py` и теряет provider-agnostic мост.
- `git status --porcelain` — fallback, не primary: при параллельных
user-edits даёт лишние файлы.