---
name: sync
description: Rebuild PROJECT_STATE derived fields from artifact files
---

# /polisade:sync — Sync State from Artifact Files

**Первое действие при `$ARGUMENTS` с `--help` или `-h`:** выполнить ровно
эту команду, показать её stdout и завершить `/polisade:sync`:

```bash
env ${POLISADE_PYTHON:-python3} {plugin_root}/scripts/polisade_sync.py --help
```

<!-- 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 -->

Это терминальная ветка. Корень проекта, state-файлы, Git и артефакты для
справки не нужны. Не подменяй путь к скрипту догадкой или путём исходного
чекаута: используй shell-выражение из команды выше.

Сканирует файлы артефактов, пересобирает derived-поля в PROJECT_STATE.json (readyToWork, inProgress, blocked, waitingForPM, inReview, artifactIndex).

## Использование

```
/polisade:sync             # Показать diff (dry-run по умолчанию)
/polisade:sync --apply     # Показать diff и записать после подтверждения
/polisade:sync --help      # Справка CLI без чтения проекта
```

## Алгоритм

0. После терминальной ветки справки разобрать `$ARGUMENTS`: допустимы только
   `--apply`, `--yes`, `--dry-run`, `--pr-body=<путь.md>`, `--help`, `-h`.
   Неизвестный аргумент — ошибка с перечнем допустимых флагов, без сканирования
   проекта. `--dry-run` имеет приоритет над `--apply`; в этом случае шаг 4
   не выполнять. Не передавать непроверенный `$ARGUMENTS` в shell.
1. Определить корень проекта. Preflight: `${POLISADE_PYTHON:-python3} --version`
   — не стартовал, значит **STOP**: интерпретатор по машине не ищем, просим PM
   выставить `POLISADE_PYTHON` (см. капсулу в `/polisade:migrate`).
2. Запустить скрипт в режиме dry-run:

<!-- 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 -->

```bash
${POLISADE_PYTHON:-python3} {plugin_root}/scripts/polisade_sync.py {project_root}
```

3. Распарсить JSON-ответ, показать diff пользователю.

   Если `status: invalid_derived_lists` (rc=1) — **STOP**. Покажи `issues[]`
   и `action` дословно. Дерево не менялось; не заменяй legacy-объект строкой
   по его `id` без сохранения остальных полей в долговечной записи.

   ⛔ **Ответ PM начинается с итога прогона и пути к полному отчёту** — то же
   правило, что в `/polisade:migrate` шаг 4, и по той же причине: замерено,
   как модель свернула список в придуманные группы и назвала PM неверные
   числа, по которым он и решает. Блок `ОТЧЁТ ДЛЯ PM` скрипт напечатал сам и
   сохранил файлом; путь — готовой строкой под блоком (поле `pm_report.line`),
   назови его как есть. Статус и числа — только из JSON (`summary`). Список
   расхождений показывай как есть. Никаких чисел, которых нет в ответе скрипта.
4. Если пользователь подтверждает — применить:

<!-- 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: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_sync.py {project_root} --apply --yes \
  --pr-body={project_root}/.polisade/tmp/pr-body.md
```

Для неинтерактивного использования (CI, pipe):

<!-- 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: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_sync.py {project_root} --apply --yes \
  --pr-body={project_root}/.polisade/tmp/pr-body.md
```

После синхронизации проверь `unknown_statuses`: запись с причиной
`missing_status_reason` / `not_actual_requires_bug` /
`missing_superseded_by` / `unexpected_status_reason` /
`unexpected_superseded_by` требует чтения исходного артефакта и решения PM.
`cancelled`, `not_actual` и `not_applicable` с причиной остаются в
`artifactIndex` (включая `status_reason`), но не входят в активные списки и
не считаются выполненной работой. Нулевой exit `sync` этого не доказывает.

### Блок `numbering` — единственное место, где артефакт получает номер

Его читают вслух, а не пропускают:

* `on_trunk: false` → номера не выдаются, и `why` говорит почему. Это НОРМА на
  ветке; в `pending` видно, что было бы выдано. Молча пропустить этот блок
  нельзя: читатель решит, что нумеровать было нечего.
* `assigned` → `TASK-<семя>` стал `TASK-007`: файл переименован, `id:`
  переписан, `seed:` остался, машинные ссылки (`parent:`, `requirements:` и
  соседи) поехали за номером. У пакета DESIGN номер живёт в имени КАТАЛОГА, а
  не файла, поэтому переименовывается каталог целиком, а `id:` правится и в
  `README.md`, и в `manifest.yaml` — схема пакета требует, чтобы они совпадали
  (до 3.7.21 такой пакет вообще нельзя было пронумеровать, и отказ
  останавливал sync всего проекта). **Проза не тронута** — ссылку, сделанную до нумерации, читает
  разрешитель `polisade_id.py resolve <семя>` (вызов — как у прочих скриптов
  проекта, см. шаг 2).
* `"status": "seed_problems"` (rc≠0) → семя не может разрешить ссылку: его нет, оно
  не совпадает с хвостом `id`, или одно семя названо у двух артефактов. Sync
  такой артефакт НЕ нумерует — выдача номера необратима, и делать её до того,
  как ссылка станет разрешимой, нельзя. Сюда же попадает артефакт, **путь
  которого выходит за границу проекта**: каталог артефактов —
  симлинк наружу. Проверяется весь путь от корня, а не только сам файл:
  обычный файл внутри симлинк-каталога проходил проверку файла, и
  переименование с перезаписью `id:` уходило в чужое дерево при `rc=0`.
* `"status": "numbering_failed"` (rc≠0) → номер не выдан или ссылка не переписана.
  Дерево дальше не трогается; чинить и повторять.
* `collisions` → два клона одного транка выдали ОДИН номер, и sync развёл их
  сам. Перенумеровывается ПОЗДНИЙ по `(created, seed)` — порядок
  тот же, в котором номера выдаются, поэтому двое, синхронизирующих одно и то
  же слитое дерево, получат один ответ. Это безопасно ровно потому, что
  идентичность живёт в семени: `kept` в строке говорит, какой файл номер
  сохранил.
* `"status": "duplicate_ids"` (rc≠0) теперь РАЗЛИЧАЕТ случаи. `recoverable` —
  что дерево позволяет развести; `blocked` — почему нельзя, по каждому номеру
  отдельно: у файлов нет разных семян (это копия артефакта, а не гонка), или
  на спорный номер УЖЕ ССЫЛАЮТСЯ — после слияния два артефакта могут нести
  `parent: TASK-001`, имея в виду разные задачи, и из файла не видно, какую.
  Ссылающиеся файлы названы поимённо: это ручная работа с известными
  границами, а не тупик. На ветке и в dry-run гонка не лечится, и ответ прямо
  говорит, что лечится она повторным `--apply` на транке.

## Формат вывода

`polisade_sync.py` всегда печатает **один** JSON-документ на stdout
(контракт OPS-108 — `json.loads(stdout)` обязан проходить). PM-friendly
сообщения и подтверждение интерактивного prompt'а уходят на stderr.
Полная таблица контрактов — `docs/config-reference.md` § Script JSON
output contracts.

### Если всё синхронизировано

```json
{
  "status": "in_sync",
  "artifacts_scanned": 12,
  "conventions": {
    "status": "in_sync",
    "path": "docs/conventions",
    "files": ["docs/conventions/architecture.md"]
  },
  "touched_paths": [],
  "stage_paths": []
}
```

### Если обнаружен drift (dry-run)

```json
{
  "status": "drift_detected",
  "artifacts_scanned": 12,
  "changes": [
    {"field": "readyToWork", "added": ["TASK-005"], "removed": ["TASK-003"]},
    {"field": "inProgress", "added": ["TASK-003"]},
    {"field": "artifactIndex", "added": ["TASK-005"], "changed": ["TASK-003"]}
  ],
  "touched_paths": [".state/PROJECT_STATE.json", ".state/counters.json"],
  "stage_paths": [".state/PROJECT_STATE.json", ".state/counters.json"],
  "dry_run": true
}
```

### После `--apply --yes`

```json
{
  "status": "applied",
  "artifacts_scanned": 12,
  "changes": [{"field": "readyToWork", "added": ["TASK-005"]}],
  "touched_paths": [".state/PROJECT_STATE.json", ".state/counters.json"],
  "stage_paths": [".state/PROJECT_STATE.json", ".state/counters.json"]
}
```

### Поле `summary` и опция `--pr-body`

`summary` — готовая к дословной печати строка отчёта, присутствует в
`in_sync`, `drift_detected` и `applied`. Счёт делает скрипт. Эта же строка
уезжает в блок `ОТЧЁТ ДЛЯ PM`, который скрипт печатает на stderr (и тем же
значением кладёт в поле `pm_block`): блок собран ИЗ полей ответа. Тот же
блок байт в байт лежит файлом в `.state/pm-reports/`, путь — в поле
`pm_report` и готовой строкой под блоком, как в `/polisade:migrate`.

`--pr-body=<путь.md>` кладёт по указанному пути готовое тело PR (markdown) из
ТОГО ЖЕ отчёта: сводка, расхождения по полям, затронутые пути с пометкой «вне
коммита», неизвестные статусы, блок нумерации, слот правил. Ответ получает
поле `pr_body` — `{"path": ..., "status": "written"}`,
`{"status": "failed", "detail": ...}` либо `{"status": "skipped", "detail": ...}`
(прогон не подтверждён — отменён или отказан за отсутствием человека). `failed` и `skipped` называются в теле PR прямым текстом;
подменять тело сообщением коммита молча нельзя.

### Слот правил команды

Поле `conventions` есть в КАЖДОМ ответе — в том числе когда слот не настроен.
«sync не проверял» не должно читаться как «правил нет».

| `conventions.status` | Что значит |
|---|---|
| `in_sync` | список файлов в `knowledge.json` совпал с папкой |
| `drift` | появился/исчез файл правил — в `changes` строка `conventions.files` |
| `absent` | слот объявлен, но папки `docs/conventions/` в проекте нет |
| `not_configured` | в `knowledge.json` нет блока `conventions` — прогони `/polisade:migrate --apply` |
| `no_knowledge` | нет `.state/knowledge.json` вовсе |

При `drift` и `--apply` sync пишет в `knowledge.json` **ровно одно поле** —
`conventions.files`, перечитав файл прямо перед записью. Содержимое файлов
правил он **не читает и не пересказывает**: это список, а не синтез, и ручные
правки команды не перезатираются. Файл `README.md` в корне папки — каркас от
`/polisade:init`, в список он не попадает (иначе «правил нет» было бы не
отличить от «лежит один каркас»).

`touched_paths` — всё, что sync тронул (для информации и для diff-сверки
с `git status --porcelain`). **Нумерация тоже сюда входит**:
новое имя артефакта, покинутое старое и каждый файл, где переписана машинная
ссылка. Раньше здесь были только `.state/*`, и штатный apply с выданным
номером возвращал `stage_paths: []` — рецепт ниже останавливался на своей же
сверке, а уже сделанное изменение публиковать было нечем.

`stage_paths` — subset для `git add`: исключает пути под `.gitignore` **и
пути, которые `git add` отверг бы**. Второе — не перестраховка, а замер:
после `git mv` покинутого имени нет ни в индексе, ни в рабочем дереве, и
`git add <старое имя>` выходит с кодом 128 «pathspec did not match any
files»; переименование при этом уже застейджено целиком. А когда `git mv`
не сработал и файл переехал обычным `rename`, старое имя ОСТАЁТСЯ в индексе
— и тогда оно в списке есть, иначе удаление не попало бы в коммит.

Сверка с `git status --porcelain` поэтому идёт по ПУТЯМ, а не по строкам:
одна строка `R  старое -> новое` описывает два пути, из которых в
`stage_paths` штатно лежит только новый. Совпадать должно множество путей
за вычетом игнорируемых, а не число строк. Путь в `stage_paths` может быть
КАТАЛОГОМ — так переезжает пакет DESIGN, и такой путь покрывает всё, что под
ним: в `git status` это будет несколько строк, и каждая из них засчитывается
своему каталогу.

### Если состояние не мигрировано (abort, rc=1)

```json
{
  "status": "migration_required",
  "current_schema": 5,
  "required_schema": 6,
  "legacy_version_key": true,
  "reason": "schemaVersion 5 < 7; legacy `pdlcVersion` key present",
  "action": "run /polisade:migrate --apply before this command"
}
```

`sync` **отказывается** реконсилить state, который ещё не прошёл
`/polisade:migrate` после переименования pdlc→polisade (legacy-ключ
`pdlcVersion` или `schemaVersion < 7`) — иначе он переписал бы derived-поля,
оставив legacy-ключи на месте (ADR-0001). При этом статусе
**не коммить и не повторяй sync**: сначала прогони `/polisade:migrate
--apply`, затем снова `/polisade:sync`. State при аборте не тронут.
Если одновременно есть неверный элемент derived list, первым придёт
`invalid_derived_lists`: он указывает поле, которое сперва надо восстановить,
иначе `/polisade:migrate` тоже откажет.

## Важно

- **По умолчанию dry-run** — проект не меняется без `--apply`; пишется только
  файл отчёта для PM в `.state/pm-reports/` (локальный, в git не попадает)
- При `--apply` показывает diff и спрашивает подтверждение
- `--apply --yes` пропускает подтверждение (для CI/pipe)
- Перестраивает: `readyToWork`, `inProgress`, `blocked`, `waitingForPM`, `inReview`
- Обновляет `artifactIndex` — безопасный индекс всех артефактов
- При объекте, смеси типов или неверном типе любого из пяти derived lists
  возвращает `invalid_derived_lists` с путём и инструкцией восстановления
  до нумерации или записи. Ни метаданные, ни state не перезаписываются.
- Перечисляет файлы правил команды в `knowledge.json :: conventions.files`
  — только список, содержимое не читается
- **Не перезаписывает** `artifacts` если в нём структурированные данные (только flat index)
- Для диагностики без изменений используй `/polisade:doctor`

## После применения — закоммить и открыть PR

После `/polisade:sync --apply` рабочее дерево обычно содержит изменения
(`.state/PROJECT_STATE.json`, иногда `.state/counters.json`,
`tasks/TASK-*.md` если PM правил статусы вручную, и т. п.). PM в
корп-сессии после этого попросит «закоммить и сделай 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` запрещён (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 дословно. Замену НЕ подбирать: каталог вместо перечисления путей
#    расширяет коммит молча.

# 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 (типичный корп-сценарий) — берём union stage_paths из
#    обоих JSON-ответов. Safety-net: git status --porcelain -uall (минус
#    игноры; без -uall новый каталог схлопывается в ОДНУ запись)
#    должен совпадать с union'ом ПО ПУТЯМ, а не по строкам: строка вида
#    «R  старое -> новое» описывает ДВА пути, и старого в union'е штатно нет —
#    git mv уже застейджил перенос целиком. Иначе СТОП и вопрос PM.

# 2. Имя ветки. Из контекста скилла:
#    sync в одиночку                → polisade-sync-<YYYY-MM-DD>
#    migrate в одиночку             → polisade-migrate-schema-<N>      (N = target_schema)
#    migrate + sync в одной сессии  → polisade-housekeeping-<YYYY-MM-DD>
#
# 2a. Имя детерминированное, значит вторая попытка даёт ТО ЖЕ имя: проверь
#     занятость ДО создания ветки, иначе узнаешь на push — после коммита,
#     а форс-пуш запрещён.
git ls-remote --heads origin <branch>
#     Пусто → имя свободно. Не пусто → НЕ форси: возьми суффикс <branch>-2
#     (и проверь снова) либо остановись и спроси PM, что с занятой веткой.
git switch -c <branch>

# 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 (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"

```

Дальше идут вызовы 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 даёт лишние файлы.
