ревью двумя проходами: 20 находок, все починены

Два независимых сабагента на av-dev-pm и av-dev-pipeline. Две находки нашли оба.

Главная — моя же перестановка закрытия за коммит сломала reopen и батч. close
печатал «дорога назад из git», а reopen искал коммит удаления, которого в новом
порядке ещё нет: шаг 11 последний, учёт остаётся незакоммиченным. Проверено
прогоном — отказ кодом 2 на свежезакрытой задаче. Тем же грязным деревом
ломались rebase и worktree remove в батче: каждая закрывшая задачу ветка уехала
бы в провалившиеся.

Починено с обеих сторон: reopen берёт текст из HEAD, если коммита удаления нет,
а шаг 11 коммитит учёт вторым коммитом.

Вторая — канонический пример docs/.pm.json убивал tasks.py. Четыре документа
показывали ключ tasks.sections, которого скрипт не знает: неизвестный ключ это
код 3 на любой команде. Проект, заведённый по канону дословно, остался бы без
работы с задачами, а docs.py при этом печатал «канон соблюдён». Секции живут в
заголовках индекса и второго дома не получают.

Остальные восемнадцать: init писал конфиг в упразднённый .tasks.json;
looks_like_tasks не видел переименованный индекс; урожай спринта терял автотег
после sprint close; ответ на вопрос по инструкции оставлял задачу незабираемой;
adopt требовал недостижимого зелёного; путь отчёта триажа не переживал archive;
review-specs не имел режима для стыка после слияния; три остатка «шаг 9а» несли
предкоммитную позицию закрытия; sprint.md отрицал сам себя в пункте «Сделана».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-03 15:54:15 +03:00
co-authored by Claude Opus 5
parent c692436b91
commit 1fb006df4a
18 changed files with 294 additions and 79 deletions
+12 -4
View File
@@ -88,7 +88,9 @@ capability: незаполненный канон это переходное с
### 1. Осмотрись
`docs.py check` — он уже назовёт упразднённые слоты с адресом, куда каждый
уезжает. Плюс прочитай: `CLAUDE.md`, корневые `*.md`, `openspec/specs/` (список
уезжает. **Но смотрит он только верхний уровень `docs/`:** упразднённое в корне
репозитория (`BRIEF.md`) и во вложенных каталогах он не назовёт никогда, поэтому
корневые `*.md` читаются глазами. Плюс: `CLAUDE.md`, `openspec/specs/` (список
capability), `openspec/config.yaml`.
### 2. Составь карту
@@ -135,9 +137,15 @@ capability), `openspec/config.yaml`.
пропускаться. Передай ему базу диффа (`--base`) той же переменной, что и
остальным шагам гейта: без неё сверка миграций со схемой не гоняется вовсе.
Пример строки покажи человеку — гейт принадлежит проекту, и правит его он;
8. `docs.py check` — до **отсутствия дрейфа**. Замечания (незаполненные
плейсхолдеры, слабое упоминание capability) остаются: незаполненный канон это
объявленное переходное состояние из шага 5, а не отказ.
8. `docs.py check` — до **отсутствия дрейфа раскладки**. Замечания
(незаполненные плейсхолдеры, слабое упоминание capability) остаются:
незаполненный канон это объявленное переходное состояние из шага 5, а не
отказ. **Пункт «задачи без цели» из вложенной проверки `tasks.py` тоже
остаётся** и зелёным на этом шаге не станет: цели не сочиняются адаптацией
(запрет в [tasks/references/adopt.md](../tasks/references/adopt.md)), их
проставляет человек порциями переоценки на первой сессии. Пересчитай эти
пункты в докладе переходного состояния — не выдавай их за поломку и не
молчи о них.
### 5. Объяви переходное состояние
+8 -1
View File
@@ -287,7 +287,7 @@ kebab-case.
"canon": 1,
"migrations": "internal/store/migrations",
"tasks": {
"sections": ["ядро", "инфра"]
"backlog": "INDEX.md"
}
}
```
@@ -297,6 +297,13 @@ kebab-case.
каталога миграций, если БД есть; по нему `docs.py` делает сверку с
`database.md`. `tasks` — настройки каталога задач, переехавшие сюда из прежнего
`<tasks>/.tasks.json`: **один конфиг на весь канон, а не по одному на каталог**.
Внутри `tasks` — **только имена файлов и заголовков** (`items`, `backlog`,
`plan`, `sprint`, `rejected`, `sprint_section`, `questions_heading`,
`criteria_heading`, `oracle_word`), и ключ пишется, лишь когда имя отличается от
умолчания. **Секций беклога здесь нет:** их дом — заголовки `##` самого индекса,
и второй список сразу разошёлся бы с первым. Неизвестный ключ `tasks.py`
отвергает кодом 3, поэтому лишнее слово в этом объекте останавливает работу с
задачами целиком.
Ключей будет больше по мере роста проверок; неизвестный ключ `docs.py`
игнорирует, отсутствующий — считает «проверка неприменима» и говорит об этом
@@ -366,5 +366,7 @@ severity стоит здесь, а не выводится каждым прох
}
```
Плюс `"migrations": "<путь>"`, если есть БД, и `"tasks": {"sections": [...]}`,
если секции беклога отличаются от умолчания.
Плюс `"migrations": "<путь>"`, если есть БД. Ключ `"tasks"` заводится **только**
когда имя файла или заголовка отличается от умолчания (`{"backlog":
"INDEX.md"}`); секций беклога в нём нет — их дом заголовки `##` индекса. Состав
ключей — [canon.md](canon.md).
+10 -6
View File
@@ -142,14 +142,17 @@ python3 $tk sprint close --dir D # конец спринта; --d
python3 $tk reopen <слаг> --dir D --reason … # приёмка не сошлась после закрытия
```
`D` — каталог задач проекта; цепочка его разрешения и вызов из чужого контекста
описаны в скилле `tasks` («Переносимость»). **Коды выхода** — там же: 1 это
дрейф в беклоге, 3 это «каталога нет», и ветвиться на них надо по-разному.
`D` — каталог задач проекта, по канону всегда `docs/tasks`; `--dir` передаётся
явно каждой командой. Вызов из чужого контекста описан в скилле `tasks`
(«Переносимость»). **Коды выхода** — там же: 1 это дрейф в беклоге, 3 это
«каталога нет», и ветвиться на них надо по-разному.
**Слаг спринта заводит `sprint start`** (по умолчанию — дата) и пишет его в
`SPRINT.md`; всё заведённое при открытом спринте помечается `sprint:<слаг>`
`SPRINT.md`; всё заведённое **при открытом спринте** помечается `sprint:<слаг>`
автоматически. Поэтому «первая порция — урожай прошедшего спринта» работает без
чьей-либо памяти.
чьей-либо памяти — но ровно до команды `sprint close`, которая `SPRINT.md`
очищает. Отсюда порядок: **урожай заводится до закрытия, слаг для сессии берётся
из отчёта `sprint close`** ([references/sprint.md](references/sprint.md)).
Правки задач делаются мутациями (`edit`, `move`, `close`), а не редактором:
руками правится только тело файла. Это правило скилла `tasks`, здесь оно не
@@ -165,7 +168,8 @@ python3 $tk reopen <слаг> --dir D --reason … # приёмка не со
пайплайн физически не мог. Теперь мост есть, и защита у трёх обходов ниже —
**только текстовая**. Опоры, которые остались настоящими:
- **отчёт триажа** в `openspec/changes/<id>/review/` — независимый артефакт,
- **отчёт триажа** в `openspec/changes/archive/<id>/review/` (до архивации —
`changes/<id>/review/`) — независимый артефакт,
написанный ревью, а не исполнителем; по нему сверяют состав прогона и урожай;
- **`SPRINT.md` под git** — `git log -p` показывает, что и когда было закрыто;
- **`reopen <слаг> --reason`** — закрытие не окончательно. Приёмка человеком на
@@ -21,9 +21,11 @@
2. **Сформулируй развилку** с вариантами и последствием каждого, рекомендация —
первым вариантом.
3. **Вынеси пачкой** через `AskUserQuestion`, не больше трёх за раз.
4. **Ответ записывается в тело задачи**, тег снимается `edit <slug> --rm-tag
question`, **хук переписывается**: «Решено: …» на вопрос «почему это лежит в
беклоге» уже не отвечает.
4. **Ответ записывается в тело задачи, раздел «Вопросы» опустошается**, тег
снимается `edit <slug> --rm-tag question`, **хук переписывается**: «Решено:
…» на вопрос «почему это лежит в беклоге» уже не отвечает. Опустошение
раздела — не уборка, а условие взятия: правило и причина в скилле `tasks`,
[references/task-format.md](../../tasks/references/task-format.md).
**Вопросы на задачах-кандидатах разбираются вне очереди порции** — здесь же, на
этой сессии, даже если сама задача в порцию переоценки не попала. Иначе правило
@@ -69,9 +71,10 @@
задачи, заведённые за спринт; при урожае в 15 это две-три порции.
- **Отбор порций по порядку:**
1. **урожай спринта**`list --tag sprint:<слаг>`: свежезаведённое ещё не
проходило ни одной проверки на нужность. Слаг спринта берётся из
`SPRINT.md` (его завёл `sprint start`), тег на задачах проставлен
автоматически при заведении — руками не метят и не вспоминают;
проходило ни одной проверки на нужность. Тег на задачах проставлен
автоматически при заведении — руками не метят и не вспоминают. **Слаг
берётся из отчёта `sprint close`, а не из `SPRINT.md`:** сессия идёт после
закрытия, а закрытие этот файл очищает;
2. дальше **по залежалости**`list --stale`;
3. по потребности — одна секция целиком, один тег (партия ревью), одна цель
(`--goal`), список от пользователя.
+12 -3
View File
@@ -11,8 +11,9 @@
след.
- **Сделана** — по определению готовности ниже. `close <slug> --implemented`:
файл и строка удаляются, следом остаётся коммит. **Закрывает владелец спринта
и только после вердикта приёмки** — см. «Кто и когда закрывает».
файл и строка удаляются, следом остаётся коммит. **Закрывает агент-оркестратор
последним шагом пайплайна, после коммита; приёмка человеком идёт позже и
отменяется `reopen`** — см. «Кто и когда закрывает».
- **Вышла из спринта** — `sprint drop <slug> --reason …`: возвращается в беклог
с вопросом в файле и **без живого незакоммиченного предложения** — иначе при
следующем взятии оно столкнётся с новым. Наработки, которые жалко терять,
@@ -35,6 +36,13 @@
сессии поднимается одной командой `list --tag sprint:<слаг>`. Спринт, закрытый
без этого шага, оставляет находки жить в отчётах — то есть нигде.
**Порядок здесь обязателен: урожай заводится ДО команды `sprint close`.**
Автотег ставится по слагу из `SPRINT.md`, а `sprint close` этот файл очищает;
заведённое после команды остаётся без тега и в первую порцию следующей сессии
не попадёт — молча, потому что пустой `list --tag` выглядит как «урожая не
было». Если так уже вышло, тег ставится руками: `add … --tag sprint:<слаг>`,
слаг берётся из отчёта `sprint close`.
**Провал спринта.** Сработал блокер — спринт распускается (`sprint close
--dissolve --reason …`), недоделанное возвращается в беклог, новый набор
делается после ответа человека. Спринт не «ждёт»: ждать может человек, а
@@ -99,7 +107,8 @@
2. **Принимает человек на сессии, а не отдельный агент.** Декорреляция
исполнителя и приёмщика в момент закрытия **снята** (решение о снятии и его
цена — в `SKILL.md`, «Стимулы»). Опоры остались три: сохранённый отчёт триажа
в `openspec/changes/<id>/review/` — независимый артефакт, `SPRINT.md` под git
в `openspec/changes/archive/<id>/review/` (до архивации — `changes/<id>/review/`)
— независимый артефакт, `SPRINT.md` под git
и `reopen`. Переоценка на сессии и есть момент, когда критерии видит не
исполнитель.
3. **Расхождение — дефект критериев.** Приёмщик правит критерии и возвращает
+22 -14
View File
@@ -95,8 +95,9 @@ docs/tasks/
скрипт запретит. Единственная оговорка: цель без задач неотличима — «ещё не
разобрана» или «всё закрыто». Различает **тег `decomposed`** в мета-строке
цели: он ставится, когда цель разложена на задачи. Тег, а не строка в теле —
потому что проверяется механически: `check` требует его у пустой цели, а
`check --fix` сам проставляет его цели, у которой задачи есть.
потому что проверяется механически: `check` **напоминает** о нём у пустой цели
(замечанием, не ошибкой — неразобранная цель это законное состояние), а `check
--fix` сам проставляет его цели, у которой задачи есть.
- **`[goal]` и `[epic]` — разные вещи.** Цель **постоянна**: живёт, пока живёт
направление. Эпик **временен**: это задача, которая не мерджится целиком, её
разбирают, и он исчезает. Два срока жизни под одним словом разъезжаются,
@@ -163,9 +164,11 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап
правки**, даже если правил мутациями: дрейф мог накопиться раньше. Накопившееся
чини `check --fix` — он детерминированно правит то, где истина однозначна
(секция, заголовок, дубли, хук из индекса в файл, строка в чужом индексе,
пометка `decomposed` у цели с задачами), а неоднозначное (ссылка на исчезнувший
файл, задача сразу в двух индексах) печатает отдельной пометкой
`НЕОДНОЗНАЧНО`это тебе, и это идёт строкой доклада.
пометка `decomposed` у цели с задачами), а неоднозначное (задача сразу в двух
индексах, нечего восстанавливать) печатает отдельной пометкой `НЕОДНОЗНАЧНО`
это тебе, и это идёт строкой доклада. **Ссылка на исчезнувший файл в пометку не
попадает:** `--fix` её просто не трогает, и она остаётся `ОШИБКА` обычного
`check` — то есть видна, но в докладе её надо назвать отдельно.
`--fix` правит **и файлы** — ровно в двух местах, где источник ровно один и
выбирать не из чего: хук, оставшийся только в индексе, переезжает в мета-строку,
@@ -250,7 +253,8 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап
`question` (`edit --add-tag question`), иначе он не виден ни `list
--questions`, ни правилу «задача с открытым вопросом в набор не берётся»;
- **тег, который некому снять** — `question` после ответа снимается `edit
--rm-tag question` вместе с записью ответа в тело;
--rm-tag question` вместе с записью ответа в тело **и опустошением раздела
«Вопросы»**: судит раздел, а не тег (`references/task-format.md`);
- **свойство репозитория в рамках** — номер миграции, хеш, версия зависимости:
в лежалой задаче протухает молча и становится ложной рамкой. Снимается;
снимок берётся при постановке, а не при заведении;
@@ -265,15 +269,19 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап
просто каталог markdown. Текст задач — русский (язык документации проекта);
зашита только латиница слага. OpenSpec ему тоже не нужен.
- **Каталог задач — `docs/tasks`, жёстко.** Цепочки разрешения нет: раскладка
канона одинакова во всех проектах, и искать больше нечего. Каталога нет — код
3 и вопрос человеку; `init` заводит его **только** когда проект действительно
новый, а перевод чужой раскладки делает `av-dev-pm:canon`.
- **Настройки живут в `docs/.pm.json`**, ключ `tasks`: секции беклога и имена
индексов, если они отличаются от умолчания. Один конфиг на весь канон, а не по
одному на каталог.
- **Каталог задач — `docs/tasks`, жёстко**, и `--dir` передаётся явно всегда:
раскладка канона одинакова во всех проектах, и искать больше нечего. Каталога
нет — код 3 и вопрос человеку; `init` заводит его **только** когда проект
действительно новый, а перевод чужой раскладки делает `av-dev-pm:canon`.
У скрипта поиск вверх по дереву ещё жив — он для непереведённых проектов, и
полагаться на него скилл не должен: молча найденный чужой каталог это дрейф.
- **Настройки живут в `docs/.pm.json`**, ключ `tasks`: **имена** файлов и
заголовков, и только если они отличаются от умолчания. Один конфиг на весь
канон, а не по одному на каталог. Неизвестный ключ — код 3 на любой команде,
так что лишнее слово в этом объекте останавливает работу с задачами целиком.
- **Секции беклога** берутся из заголовков `##` индекса как есть; их количество
и названия — дело проекта (умолчание `ядро` / `инфра`).
и названия — дело проекта (умолчание `ядро` / `инфра`). **В конфиге их нет** —
второй список разошёлся бы с заголовками молча.
### Вызов из другого плагина
+2 -1
View File
@@ -64,7 +64,8 @@ python3 $tk adopt apply --plan tasks-adopt-plan.json \
1. **Осмотрись.** Где лежат задачи, план, заметки. Каталог задач по канону —
всегда `docs/tasks`. Секции беклога (`--sections`) — по умолчанию
`ядро,инфра`; если у проекта деление другое по существу, оно называется
здесь, а не подгоняется под умолчание, и уезжает в `docs/.pm.json`.
здесь, а не подгоняется под умолчание, и становится **заголовками `##`
индекса** — их единственным домом. В `docs/.pm.json` секции не пишутся.
2. **`adopt scan`** по всем источникам разом. Один прогон, одна карта: два
прохода дадут два несогласованных состояния.
3. **Заполни карту**: `slug` (английский), `section`, `goal` у каждой записи;
@@ -92,9 +92,15 @@
отсутствие при непустом разделе — замечание, а не лазейка. Тег без раздела тоже
отказ, но с другим советом: либо вопрос записан не туда, либо тег пора снять.
Ответ записывается в тело, тег снимается `edit <slug> --rm-tag question`, а хук
переписывается: «Решено: …» на вопрос «почему это лежит в беклоге» уже не
отвечает.
**Ответ на вопрос — три правки, и первая обязательна.** Раздел «Вопросы»
опустошается: ответ переезжает в тело решением, а не остаётся вопросом рядом с
ответом. Затем снимается тег (`edit <slug> --rm-tag question`) и переписывается
хук: «Решено: …» на вопрос «почему это лежит в беклоге» уже не отвечает.
**Порядок именно такой, потому что судит раздел, а не тег.** `sprint take`
смотрит в непустой раздел и откажет взять задачу даже со снятым тегом, а `check`
на снятый тег при непустом разделе посоветует тег вернуть. Снять тег, не
опустошив раздел, — значит закольцевать себя между двумя советами.
## Файл цели
@@ -119,7 +125,8 @@
- **Тег `decomposed`** отличает «цель ещё не разобрана» от «все её задачи
закрыты» — два состояния, у которых снаружи один и тот же признак: задач нет.
Пометка именно **тегом**, а не строкой в теле: только так она проверяется.
`check` требует его у цели без задач, `check --fix` сам ставит его цели, у
`check` напоминает о нём у цели без задач замечанием — неразобранная цель
законна и зелёного прогона не ломает; `check --fix` сам ставит его цели, у
которой задачи есть, а цель с тегом и без задач — прямое приглашение закрыть.
- Цель живёт в `PLAN.md` и **никогда** — в `BACKLOG.md` или `SPRINT.md`.
+54 -13
View File
@@ -328,6 +328,19 @@ def _validate_config(data: dict, path: Path) -> dict:
return data
def config_home(root: Path) -> Path | None:
"""Откуда настройки читаются на самом деле — и куда, значит, слать чинить.
Порядок тот же, что в `load_config`: `docs/.pm.json` побеждает. Без этой
функции сообщения об ошибке звали править `.tasks.json`, который при живом
`.pm.json` вообще не читается.
"""
pm = (root / PM_CONFIG_REL).resolve()
if pm.is_file():
return pm
return root / CONFIG_NAME if (root / CONFIG_NAME).is_file() else None
def config_problems(lay: Layout) -> list[str]:
"""Каждый путь из конфига сверяется с диском ДО любых выводов о задачах.
@@ -335,8 +348,7 @@ def config_problems(lay: Layout) -> list[str]:
check обвинять невиновных: «ссылка на несуществующий файл», хотя файл на
месте, а мимо смотрит конфиг.
"""
src = lay.root / CONFIG_NAME
where = str(src) if src.is_file() else "умолчания (файла .tasks.json нет)"
where = str(config_home(lay.root) or "умолчания (конфига нет)")
out = []
if not lay.items.is_dir():
out.append(f"{where}: items = «{lay.cfg['items']}» → {lay.items} — каталога нет")
@@ -350,7 +362,11 @@ def config_problems(lay: Layout) -> list[str]:
def looks_like_tasks(p: Path) -> bool:
if (p / CONFIG_NAME).is_file():
return True
return (p / DEFAULTS["backlog"]).is_file()
try: # индекс мог быть переименован через docs/.pm.json
name = load_config(p).get("backlog", DEFAULTS["backlog"])
except Env:
name = DEFAULTS["backlog"]
return (p / name).is_file()
def resolve_layout(explicit: str | None) -> Layout:
@@ -626,7 +642,8 @@ def check(lay: Layout, fix: bool = False) -> int:
for p in problems:
print(f"КОНФИГ {p}")
print("\nсперва конфиг: пока он мимо, всё остальное диагностируется ложно"
f" (правь {lay.root / CONFIG_NAME} или переименуй файлы)")
f" (правь {config_home(lay.root) or lay.root / CONFIG_NAME}"
f" или переименуй файлы)")
return EXIT_ENV
if fix:
@@ -1343,16 +1360,24 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int:
def git_deleted_text(path: Path) -> str | None:
"""Текст файла из коммита, в котором его удалили. Возврат закрытой задачи
возможен ровно потому, что удаление зафиксировано историей."""
"""Текст закрытой задачи из истории git.
Два источника, и второй обязателен. Коммит удаления — обычный случай:
закрытие уже уехало в историю. Но пайплайн закрывает задачу **последним
шагом**, и между удалением файла и коммитом учёта есть окно, в котором
коммита удаления ещё нет, а текст лежит в `HEAD`. Без второго источника
`reopen` отказывал бы ровно на свежезакрытой задаче — то есть в самом
вероятном своём применении.
"""
try:
sha = subprocess.run(["git", "log", "--diff-filter=D", "--format=%H", "-n", "1",
"--", str(path)], capture_output=True, text=True).stdout.strip()
if not sha:
return None
out = subprocess.run(["git", "show", f"{sha}^:{path}"],
capture_output=True, text=True)
return out.stdout if out.returncode == 0 else None
for rev in ([f"{sha}^"] if sha else []) + ["HEAD"]:
out = subprocess.run(["git", "show", f"{rev}:{path}"],
capture_output=True, text=True)
if out.returncode == 0:
return out.stdout
return None
except FileNotFoundError:
return None
@@ -1622,6 +1647,8 @@ def cmd_sprint_close(lay: Layout, a: argparse.Namespace) -> int:
if slug:
print(f" урожай спринта — `tasks.py list --tag {SPRINT_TAG}{slug}`:"
f" завести найденное по ходу обязан закрывающий спринт, а не пайплайн")
print(f" автотег снят вместе со спринтом: заводимое СЕЙЧАС метится только"
f" вручную — `add … --tag {SPRINT_TAG}{slug}`, иначе выпадет из урожая")
print(" дальше — сессия: разбор вопросов → разбор спринта → переоценка → новый набор")
return EXIT_OK
@@ -1782,7 +1809,20 @@ def init_files(lay: Layout, sections: list[str], plan_sections: list[str],
cfg: dict) -> dict[Path, str]:
out: dict[Path, str] = {}
if cfg:
out[lay.root / CONFIG_NAME] = json.dumps(cfg, ensure_ascii=False, indent=2) + "\n"
# Дом настроек один — `docs/.pm.json`, ключ `tasks`. Писать в
# `.tasks.json` при живом `.pm.json` значит писать туда, откуда никто
# не читает: load_config его в этом случае игнорирует.
pm = (lay.root / PM_CONFIG_REL).resolve()
if pm.is_file():
data = _read_json(pm)
section = data.get("tasks") or {}
if not isinstance(section, dict):
raise Env(f"{pm}: ключ «tasks» — ожидался объект с настройками")
data["tasks"] = {**section, **cfg}
out[pm] = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
else:
out[lay.root / CONFIG_NAME] = json.dumps(cfg, ensure_ascii=False,
indent=2) + "\n"
out[lay.index("backlog")] = (
"# Беклог\n\n"
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
@@ -1848,7 +1888,8 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
print(f"каталог задач заведён: {root}")
print(f" секции беклога: {', '.join(sections)}; части плана: {', '.join(plan_sections)}")
if cfg:
print(f" имена частей записаны в {root / CONFIG_NAME}")
pm = (root / PM_CONFIG_REL).resolve()
print(f" имена частей записаны в {pm if pm.is_file() else root / CONFIG_NAME}")
return EXIT_OK