канон отпустил каталог задач: docs.py не зовёт tasks.py, конфиг разъехался

Пока владелец был один, docs/tasks/ числился слотом канона: docs.py требовал
каталог, звал внутрь чужой скрипт подпроцессом и выдавал его дрейф за свой, а
настройки задач жили ключом tasks в docs/.pm.json. Для проекта, поставившего
только документы, всё это отказ на ровном месте — задач он не ведёт, и требовать
их не за что.

Раскол вскрыл это немедленно и молча: check_tasks искал tasks.py по пути
parents[2]/tasks/scripts, то есть внутри своего плагина, и после переезда
скатывался в ветку «скрипт не найден» на каждом прогоне. Проверка выглядела
живой и не проверяла ничего.

Теперь docs.py про задачи не говорит ни слова: check_tasks снят целиком, каталог
остаётся в NOT_DOCS, его отсутствие дрейфом не считается. Канон резервирует место
в docs/ и внутрь не смотрит.

Дом настроек каталога задач вернулся в свой файл — <каталог>/.tasks.json. Прежний
ключ tasks в docs/.pm.json читается, только когда своего файла нет, и скрипт
говорит, куда его перенести; есть оба — побеждает свой, и об этом тоже говорится
вслух. Порядок именно такой, потому что docs/ принадлежит другому плагину: дом
настроек в чужом дереве это дом, которого у половины проектов нет.

Заодно закрыта дыра, которую сам же и открыл первый вариант правки: битый
docs/.pm.json ронял бы задачи даже при живом своём конфиге. Чужой файл здесь
только повод для замечания, и его поломка не наша. Проверено на четырёх случаях —
только чужой конфиг, оба, свой плюс битый чужой (код 0), только битый чужой
(код 3, окружение).

Канон повышен до версии 8 с записью, выполнимой upgrade. В ней названо и то, что
легко потерять: раньше согласованность задач тянул за собой docs.py check, и
проект, у которого в гейте стоял только он, обязан добавить второй шаг — иначе
дрейф индексов перестанет ловиться молча.

Гейт зелёный. Оба скрипта прогнаны: docs.py check на фикстуре про задачи не
упоминает, tasks.py check код 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-09 14:12:08 +03:00
co-authored by Claude Opus 5
parent 00ddfb0dde
commit 1f31ac6afd
7 changed files with 131 additions and 110 deletions
+44 -37
View File
@@ -108,8 +108,8 @@ import subprocess
import sys
from pathlib import Path
CONFIG_NAME = ".tasks.json" # прежний дом настроек, читается для совместимости
PM_CONFIG_REL = "../.pm.json" # текущий дом: docs/.pm.json, ключ "tasks"
CONFIG_NAME = ".tasks.json" # дом настроек: свой файл в каталоге задач
PM_CONFIG_REL = "../.pm.json" # прежний дом: docs/.pm.json, ключ "tasks"
EXIT_OK = 0
EXIT_DRIFT = 1
@@ -443,27 +443,43 @@ class Layout:
def load_config(root: Path) -> dict:
"""Настройки каталога задач.
Дом один — `docs/.pm.json`, ключ `tasks`: один конфиг на весь канон, а не по
одному на каталог. Прежний `<tasks>/.tasks.json` читается, пока живы проекты,
которые ещё не переехали; когда есть оба, побеждает `.pm.json`, и об этом
говорится вслух, потому что молча выбранный из двух конфиг это дрейф,
который потом никто не объяснит.
Дом — `<каталог задач>/.tasks.json`: **свой файл у своего плагина**. Ключ
`tasks` в `docs/.pm.json` читается, пока живы проекты, заведённые до раскола
плагинов, и только когда своего файла нет; когда есть оба, побеждает свой, и
об этом говорится вслух молча выбранный из двух конфиг это дрейф, который
потом никто не объяснит.
Порядок именно такой, а не наоборот, потому что `docs/` принадлежит другому
плагину. Проект, поставивший учёт задач без канона документов, каталога
`docs/` не имеет вовсе, и дом настроек, лежащий в чужом дереве, был бы домом,
которого у половины проектов нет.
"""
path = root / CONFIG_NAME
pm = (root / PM_CONFIG_REL).resolve()
if path.is_file():
# Чужой конфиг здесь только повод для замечания, поэтому его поломка не
# наша: битый `docs/.pm.json` не должен ронять задачи, у которых свой
# файл на месте и читается.
try:
stale = pm.is_file() and isinstance(_read_json(pm).get("tasks"), dict)
except Env:
stale = False
if stale:
print(f"ЗАМЕЧАНИЕ настройки взяты из {path}; ключ «tasks» в {pm}"
f" остался от прежней раскладки и не читается — убери его",
file=sys.stderr)
return _validate_config(_read_json(path), path)
if pm.is_file():
data = _read_json(pm)
section = data.get("tasks", {})
if not isinstance(section, dict):
raise Env(f"{pm}: ключ «tasks» — ожидался объект с настройками")
if (root / CONFIG_NAME).is_file():
print(f"ЗАМЕЧАНИЕ настройки взяты из {pm}; {root / CONFIG_NAME}"
f" остался от прежней раскладки и не читается — удали его",
file=sys.stderr)
if section:
print(f"ЗАМЕЧАНИЕ настройки взяты из ключа «tasks» в {pm} — это"
f" прежний дом. Перенеси их в {path}: каталог docs/ ведёт"
f" другой плагин, и его может не быть", file=sys.stderr)
return _validate_config(section, pm)
path = root / CONFIG_NAME
if not path.is_file():
return {}
return _validate_config(_read_json(path), path)
return {}
def _read_json(path: Path) -> dict:
@@ -500,14 +516,14 @@ def _validate_config(data: dict, path: Path) -> dict:
def config_home(root: Path) -> Path | None:
"""Откуда настройки читаются на самом деле — и куда, значит, слать чинить.
Порядок тот же, что в `load_config`: `docs/.pm.json` побеждает. Без этой
функции сообщения об ошибке звали править `.tasks.json`, который при живом
`.pm.json` вообще не читается.
Порядок тот же, что в `load_config`: свой `.tasks.json` побеждает. Без этой
функции сообщения об ошибке звали бы править файл, который не читается.
"""
path = root / CONFIG_NAME
if path.is_file():
return path
pm = (root / PM_CONFIG_REL).resolve()
if pm.is_file():
return pm
return root / CONFIG_NAME if (root / CONFIG_NAME).is_file() else None
return pm if pm.is_file() else None
def config_problems(lay: Layout) -> list[str]:
@@ -2734,20 +2750,12 @@ def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str],
cfg: dict) -> dict[Path, str]:
out: dict[Path, str] = {}
if cfg:
# Дом настроек один — `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"
# Пишем всегда в свой `.tasks.json`, даже когда рядом живёт
# `docs/.pm.json`: дом настроек принадлежит этому плагину, а `docs/` —
# другому, и его в проекте может не быть. load_config читает свой файл
# первым, так что записанное сюда и прочитается отсюда.
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"
@@ -2833,8 +2841,7 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
print(f" секции беклога: {', '.join(sections)};"
f" секции роадмапа канонические: {', '.join(roadmap_sections)}")
if cfg:
pm = (root / PM_CONFIG_REL).resolve()
print(f" имена частей записаны в {pm if pm.is_file() else root / CONFIG_NAME}")
print(f" имена частей записаны в {root / CONFIG_NAME}")
return EXIT_OK