конфиг: одна версия и один служебный файл, .av-dev.toml в корне

Версий было две — канон 14 в docs/.docs.json и формат задач 1 в
<каталог задач>/.tasks.json, — и порознь они двигались потому, что плагины
ставились порознь. Плагин один, версия одна и начинается с 1; журналы обеих
прежних нумераций закрыты и лежат рядом непереписанными, действующий журнал
открывается записью о слиянии с перечнем шагов проекту.

Формат TOML взят ради комментариев: файл живёт в репозитории проекта, и
назначение числа читают из него самого. Отсюда правило записи — скрипты правят
строку, а не переписывают файл. Читатель общий, shared/config.py: два разбора
одной схемы были бы двумя домами.

Каталог задач перестал узнаваться служебным файлом и называется ключом
[tasks] dir; узнают его по индексу. Прежние файлы не читаются — увидев их,
docs.py и tasks.py называют прежнюю раскладку и зовут upgrade.
This commit is contained in:
av
2026-08-13 10:30:18 +03:00
parent 6b162c421d
commit 95c9499f06
16 changed files with 1450 additions and 1117 deletions
+168 -153
View File
@@ -7,11 +7,11 @@
ровно в одном из них за раз. `REJECTED.md` индексом не считается: он не говорит,
где запись числится, он кладбище ушедшего.
Раскладка. Путь каталога — `tasks/` в корне репозитория, жёстко. Каталог
принадлежит этому плагину, а не канону документов: `docs/` ведёт другой плагин, и
проект, поставивший учёт работ без него, каталога `docs/` не имеет вовсе. Имена
внутри и **версия формата** живут в `tasks/.tasks.json`; журнал версий —
references/changelog.md рядом со скриптом.
Раскладка. Путь каталога — `tasks/` в корне репозитория по умолчанию; другой
называется ключом `[tasks] dir`. Каталог принадлежит этому скиллу, а не канону
документов: учёт работ ведут и в проекте, который к канону не приведён. Имена
частей и **версия раскладки** живут в `.av-dev.toml` в корне; журнал версий —
references/changelog.md скилла doc-canon.
tasks/
items/ задачи и цели файлами, <slug>.md
@@ -102,32 +102,47 @@ goal | feature | fix | chore | research, по-английски, как и пр
import argparse
import datetime
import importlib.util
import json
import re
import subprocess
import sys
from pathlib import Path
from types import ModuleType
CONFIG_NAME = ".tasks.json" # дом настроек и версии: свой файл в каталоге
PM_CONFIG_REL = "../.pm.json" # прежний дом настроек: docs/.pm.json, ключ "tasks"
# Версия формата задач — **своя, а не канона документов**. Число живёт ключом
# `tasks` в `.tasks.json`, журнал версий — references/changelog.md рядом со
# скриптом, повышает его операция `upgrade` скилла `av-dev:task-track`.
def _load_shared() -> ModuleType:
"""Общий читатель `.av-dev.toml` — `shared/config.py` этого же плагина.
Путь считается от файла скрипта: зовут его из репозитория проекта, где
дерева плагина в текущем каталоге нет.
"""
path = Path(__file__).resolve().parents[3] / "shared" / "config.py"
spec = importlib.util.spec_from_file_location("avdev_config", path)
if spec is None or spec.loader is None:
print(f"ОТКАЗ: не читается {path} — общий читатель настроек;"
f" переустанови плагин av-dev", file=sys.stderr)
sys.exit(3)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
conf = _load_shared()
CONFIG_NAME = conf.CONFIG_NAME # дом настроек и версии: `.av-dev.toml` в корне
# Версия раскладки — **одна на плагин**, и живёт она в `shared/config.py`.
# Своей у каталога задач больше нет: пока плагинов было три и ставились они
# порознь, проект мог иметь учёт работ без канона документов, и общее число
# было бы домом, которого у половины проектов нет. Плагин один — довод ушёл, а
# два числа вместо одного оставляли бы вопрос «по какому журналу повышать».
#
# Число именно своё, потому что плагин ставится в одиночку: проект, взявший учёт
# работ без канона документов, каталога `docs/` не имеет вовсе, а значит не имеет
# и версии канона — сверять было бы не с чем. Копия чужого числа в этом скрипте
# была бы вторым домом для одной версии и разъехалась бы молча при обновлении
# одного плагина без другого.
#
# Переезды каталога задач, случившиеся до появления этого числа (в корень —
# канон 11, отмена спринтов — канон 12), задним числом сюда не переписаны: они
# уже названы журналом канона, и второй перечень тех же шагов разошёлся бы с
# первым. Версия 1 — формат на день её появления, что бы проекту ни пришлось
# пройти до неё.
FORMAT_VERSION = 1
VERSION_KEY = "tasks"
# Переезды каталога, случившиеся до слияния (в корень, отмена спринтов), задним
# числом в журнал не переписаны: они названы прежними журналами, и второй
# перечень тех же шагов разошёлся бы с первым.
FORMAT_VERSION = conf.VERSION
VERSION_KEY = conf.VERSION_KEY
EXIT_OK = 0
EXIT_DRIFT = 1
@@ -135,6 +150,11 @@ EXIT_USAGE = 2
EXIT_ENV = 3
EXIT_INTERNAL = 4
# Ключ `dir` в DEFAULTS не входит намеренно: он говорит, **где** каталог, а не
# как названы его части, и в `Layout` (тот про имена внутри) ему делать нечего.
DIR_KEY = "dir"
DEFAULT_DIR = "tasks"
DEFAULTS = {
"items": "items",
"backlog": "BACKLOG.md",
@@ -435,9 +455,14 @@ class Layout:
"""Каталог задач и имена его частей. Всё настраивается: у соседнего проекта
может быть другой подкаталог и другие имена индексов, а семантика та же."""
def __init__(self, root: Path, cfg: dict):
def __init__(self, root: Path, cfg: dict, project: Path | None = None,
full: dict | None = None):
self.root = root
self.cfg = {**DEFAULTS, **cfg}
# Корень проекта — там, где лежит `.av-dev.toml`. Он нужен отдельно от
# каталога задач: версия объявлена в корне, а имена частей — внутри.
self.project = project or root
self.full = full or {}
self.cfg = {**DEFAULTS, **{k: v for k, v in cfg.items() if k != DIR_KEY}}
self.items = root / self.cfg["items"]
def index(self, kind: str) -> Path:
@@ -451,64 +476,28 @@ class Layout:
return ("backlog", "roadmap")
def load_config(root: Path) -> dict:
"""Настройки каталога задач и версия его формата.
def load_config(project: Path) -> dict:
"""Весь `.av-dev.toml` проекта. Секция задач берётся из него отдельно.
Дом — `<каталог задач>/.tasks.json`: **свой файл у своего плагина**. Ключ
`tasks` в `docs/.pm.json` читается, пока живы проекты, заведённые до раскола
плагинов, и только когда своего файла нет; когда есть оба, побеждает свой, и
об этом говорится вслух — молча выбранный из двух конфиг это дрейф, который
потом никто не объяснит.
Порядок именно такой, а не наоборот, потому что `docs/` принадлежит другому
плагину. Проект, поставивший учёт задач без канона документов, каталога
`docs/` не имеет вовсе, и дом настроек, лежащий в чужом дереве, был бы домом,
которого у половины проектов нет.
Версия формата (ключ `tasks`) читается **только из своего файла**: прежний
дом её не знал и знать не может, и молча выведенная из его отсутствия версия
была бы догадкой о том, что чинится одной строкой.
Дом настроек — **корень репозитория**, а не каталог задач: файл держит
версию раскладки, которая одна на плагин, и ключ `[tasks] dir`, который
говорит, где каталог лежит. Настройка внутри настраиваемого каталога не
смогла бы сказать, где он.
"""
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 section:
print(f"ЗАМЕЧАНИЕ настройки взяты из ключа «tasks» в {pm} — это"
f" прежний дом. Перенеси их в {path}: каталог docs/ ведёт"
f" другой плагин, и его может не быть", file=sys.stderr)
return _validate_config(section, pm)
return {}
def _read_json(path: Path) -> dict:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise Env(f"{path}: не разбирается как JSON — {e}") from e
if not isinstance(data, dict):
raise Env(f"{path}: ожидался объект с настройками")
data = conf.read(project)
except conf.ConfigError as e:
raise Env(str(e)) from e
_validate_config(conf.section(data, "tasks"), project / CONFIG_NAME)
return data
def tasks_section(full: dict) -> dict:
return conf.section(full, "tasks")
def _validate_config(data: dict, path: Path) -> dict:
unknown = set(data) - set(DEFAULTS) - {VERSION_KEY}
unknown = set(data) - set(DEFAULTS) - {DIR_KEY}
# Ключ «plan» был домом оглавления целей до того, как файл стал ROADMAP.md.
# Без этой ветки проект со старым конфигом получал бы «неизвестный ключ» и
# искал опечатку там, где на самом деле переименование канона.
@@ -518,19 +507,12 @@ def _validate_config(data: dict, path: Path) -> dict:
f" av-dev:doc-canon (upgrade), а не правь ключ в одиночку:"
f" файл и ссылки на него переезжают вместе с ним")
if unknown:
known = sorted({*DEFAULTS, VERSION_KEY})
raise Env(f"{path}: неизвестные ключи: {', '.join(sorted(unknown))}"
f" (известны: {', '.join(known)})")
# Версия — единственный ключ-число: остальные это имена файлов и заголовков.
# Битое число тут останавливает работу целиком (код 3), а не идёт дрейфом,
# потому что «на какой версии формата каталог» решает, чему верить дальше.
got = data.get(VERSION_KEY)
if VERSION_KEY in data and (isinstance(got, bool) or not isinstance(got, int)):
raise Env(f"{path}: ключ «{VERSION_KEY}» — версия формата задач,"
f" ожидалось целое число, а не {got!r}")
known = sorted({*DEFAULTS, DIR_KEY})
raise Env(f"{path}: неизвестные ключи в секции [tasks]:"
f" {', '.join(sorted(unknown))} (известны: {', '.join(known)})")
# Версия раскладки лежит ключом верхнего уровня и проверяется общим
# читателем: здесь судится только секция задач, и все её ключи — строки.
for key, value in data.items():
if key == VERSION_KEY:
continue
if not isinstance(value, str) or not value.strip():
raise Env(f"{path}: ключ «{key}» — ожидалась непустая строка")
if key in PATH_KEYS and (value.startswith("/") or ".." in Path(value).parts):
@@ -538,17 +520,15 @@ def _validate_config(data: dict, path: Path) -> dict:
return data
def config_home(root: Path) -> Path | None:
def config_home(lay: Layout) -> Path | None:
"""Откуда настройки читаются на самом деле — и куда, значит, слать чинить.
Порядок тот же, что в `load_config`: свой `.tasks.json` побеждает. Без этой
функции сообщения об ошибке звали бы править файл, который не читается.
Дом один — `.av-dev.toml` в корне проекта; None значит «файла нет, работаем
на умолчаниях». Без этой функции сообщения об ошибке звали бы править файл,
которого нет.
"""
path = root / CONFIG_NAME
if path.is_file():
return path
pm = (root / PM_CONFIG_REL).resolve()
return pm if pm.is_file() else None
path = lay.project / CONFIG_NAME
return path if path.is_file() else None
def config_problems(lay: Layout) -> list[str]:
@@ -558,7 +538,7 @@ def config_problems(lay: Layout) -> list[str]:
check обвинять невиновных: «ссылка на несуществующий файл», хотя файл на
месте, а мимо смотрит конфиг.
"""
where = str(config_home(lay.root) or "умолчания (конфига нет)")
where = str(config_home(lay) or "умолчания (конфига нет)")
out = []
if not lay.items.is_dir():
out.append(f"{where}: items = «{lay.cfg['items']}» → {lay.items} — каталога нет")
@@ -582,36 +562,43 @@ def version_problems(lay: Layout) -> list[str]:
бы объявить каталог приведённым к формату, шагов которого никто не делал.
Заводит число `init`, двигает — операция `upgrade` скилла.
"""
path = lay.root / CONFIG_NAME
# Прежний дом (`docs/.pm.json`) версии не знает, поэтому спрашиваем строго
# свой файл: «конфиг нашёлся» и «версия объявлена» это разные события.
path = lay.project / CONFIG_NAME
if not path.is_file():
return [f"нет {path} — версия формата задач не объявлена."
f" Заведи файл с «{VERSION_KEY}»: {FORMAT_VERSION} (журнал версий —"
f" references/changelog.md скилла av-dev:task-track)"]
# Что число целое, уже проверил `_validate_config` — иначе сюда не дошли бы
legacy = conf.legacy_files(lay.project, lay.root)
if legacy:
return [f"нет {path}, а прежняя раскладка на месте"
f" ({', '.join(legacy)}): перенеси настройки и удали старые"
f" файлы операцией upgrade скилла av-dev:doc-canon"]
return [f"нет {path} — версия раскладки не объявлена."
f" Заведи файл с «{VERSION_KEY} = {FORMAT_VERSION}» (журнал"
f" версий — references/changelog.md скилла av-dev:doc-canon)"]
# Что число целое, уже проверил общий читатель — иначе сюда не дошли бы
# вовсе (код 3). Здесь `isinstance` значит ровно «ключ есть».
got = lay.cfg.get(VERSION_KEY)
if not isinstance(got, int):
return [f"{path}: нет ключа «{VERSION_KEY}» — версия формата не объявлена,"
f" текущая {FORMAT_VERSION}"]
got = conf.version(lay.full)
if got is None:
return [f"{path}: нет ключа «{VERSION_KEY}» — версия раскладки не"
f" объявлена, текущая {FORMAT_VERSION}"]
if got < FORMAT_VERSION:
return [f"каталог приведён к формату версии {got}, текущая —"
return [f"проект приведён к раскладке версии {got}, текущая —"
f" {FORMAT_VERSION}: нужно повышение по журналу"
f" (скилл av-dev:task-track, операция upgrade)"]
f" (скилл av-dev:doc-canon, операция upgrade)"]
if got > FORMAT_VERSION:
return [f"каталог приведён к формату версии {got}, а скрипт знает"
return [f"проект приведён к раскладке версии {got}, а скрипт знает"
f" {FORMAT_VERSION}: устарел плагин, обнови маркетплейс"]
return []
def looks_like_tasks(p: Path) -> bool:
if (p / CONFIG_NAME).is_file():
return True
try: # индекс мог быть переименован через конфиг
name = load_config(p).get("backlog", DEFAULTS["backlog"])
except Env:
name = DEFAULTS["backlog"]
def looks_like_tasks(p: Path, names: dict | None = None) -> bool:
"""Каталог задач узнаётся индексом, а не служебным файлом.
Служебный файл теперь лежит в корне проекта и о каталоге говорит ключом
`[tasks] dir`; узнавать каталог по нему значило бы объявить его задачами
ровно там, куда указывает ключ, — даже если по этому пути пусто.
Имя индекса берётся из настроек: проект вправе назвать его по-своему, и
поиск по умолчанию не нашёл бы переименованного каталога вовсе.
"""
name = (names or {}).get("backlog") or DEFAULTS["backlog"]
return (p / name).is_file()
@@ -619,33 +606,51 @@ def resolve_layout(explicit: str | None) -> Layout:
"""Каталог задач для команд, кроме init.
Цепочка разрешения: явный `--dir` (обязан быть внутри рабочего каталога) →
`.tasks.json` или умолчания вверх от текущего каталога. Указатель в
`CLAUDE.md` проекта — звено между ними, но читает его агент и передаёт
сюда `--dir`: скрипт не разбирает чужую документацию.
ключ `[tasks] dir` из `.av-dev.toml` в корне → умолчание `tasks/` вверх от
текущего каталога. Указатель в `CLAUDE.md` проекта — звено между первым и
вторым, но читает его агент и передаёт сюда `--dir`: скрипт не разбирает
чужую документацию.
"""
here = Path.cwd().resolve()
project = conf.find_root(here)
full = load_config(project) if project else {}
names = tasks_section(full)
if explicit:
root = Path(explicit)
if not dir_within_cwd(root):
raise Env(f"--dir вне рабочего каталога: {explicit}")
if not looks_like_tasks(root):
if not looks_like_tasks(root, names):
raise Env(f"задач нет в «{explicit}»;"
f" новый проект — tasks.py init --dir {explicit}")
return Layout(root, load_config(root))
here = Path.cwd().resolve()
return Layout(root, names, project or root.resolve(), full)
if project:
candidate = project / names.get(DIR_KEY, DEFAULT_DIR)
if looks_like_tasks(candidate, names):
return Layout(relative_if_inside(candidate, here), names, project, full)
# Проект без `.av-dev.toml` — учёт работ ведут и до того, как канон заведён.
# Тогда каталог ищется умолчанием вверх, а версия объявится на `adopt`.
for base in (here, *here.parents):
for candidate in (base, base / "tasks", base / "docs/tasks", base / "doc/tasks"):
if looks_like_tasks(candidate):
try:
rel = candidate.relative_to(here)
except ValueError:
rel = candidate
return Layout(rel if str(rel) != "." else candidate, load_config(candidate))
for candidate in (base, base / DEFAULT_DIR, base / "docs/tasks", base / "doc/tasks"):
if looks_like_tasks(candidate, names):
return Layout(relative_if_inside(candidate, here), names,
project or base, full)
if (base / ".git").exists():
break # выше корня репозитория не ищем
raise Env("каталог задач не найден: ни --dir, ни tasks/ вверх от"
f" {here}. Путь всегда tasks/ в корне репозитория; прежний"
" docs/tasks переезжает по записи 11 журнала версий канона,"
" новый проект — tasks.py init --dir tasks")
raise Env(f"каталог задач не найден: ни --dir, ни ключ [tasks] {DIR_KEY} в"
f" {CONFIG_NAME}, ни {DEFAULT_DIR}/ вверх от {here}."
f" Новый проект — tasks.py init --dir {DEFAULT_DIR}")
def relative_if_inside(path: Path, here: Path) -> Path:
"""Путь покороче для сообщений, если каталог лежит под текущим."""
try:
rel = path.relative_to(here)
except ValueError:
return path
return path if str(rel) == "." else rel
# --- Чтение индексов ---
@@ -1079,7 +1084,7 @@ def check(lay: Layout, fix: bool = False) -> int:
for p in problems:
print(f"КОНФИГ {p}")
print("\nсперва конфиг: пока он мимо, всё остальное диагностируется ложно"
f" (правь {config_home(lay.root) or lay.root / CONFIG_NAME}"
f" (правь {config_home(lay) or lay.project / CONFIG_NAME}"
f" или переименуй файлы)")
return EXIT_ENV
@@ -2596,15 +2601,14 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str],
cfg: dict) -> dict[Path, str]:
out: dict[Path, str] = {}
# Файл заводится всегда, даже когда все имена умолчательные: в нём живёт
# версия формата, а версия — не настройка, от которой можно отказаться.
#
# Пишем всегда в свой `.tasks.json`, даже когда рядом живёт `docs/.pm.json`:
# дом настроек принадлежит этому плагину, а `docs/` — другому, и его в
# проекте может не быть. load_config читает свой файл первым, так что
# записанное сюда и прочитается отсюда.
out[lay.root / CONFIG_NAME] = json.dumps(cfg, ensure_ascii=False,
indent=2) + "\n"
# Служебный файл заводится всегда, даже когда все имена умолчательные: в нём
# живёт версия раскладки, а версия — не настройка, от которой можно
# отказаться. Файл уже есть (проект под каноном, заводят только задачи) —
# он не перезаписывается: комментарии в нём принадлежат человеку. Тогда
# недостающие ключи секции дописываются построчно, и делает это `cmd_init`
# после записи файлов, потому что правка идёт по живому файлу, а не планом.
if not (lay.project / CONFIG_NAME).is_file():
out[lay.project / CONFIG_NAME] = conf.skeleton(FORMAT_VERSION, tasks=cfg)
out[lay.index("backlog")] = (
"# Беклог\n\n"
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
@@ -2670,8 +2674,18 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
# Имена частей — следом и только те, что названы явно: умолчание, записанное
# в файл, стало бы вторым домом для того же имени. В раскладку версия не
# идёт — `Layout` про имена, и число среди имён там ничего не значит.
cfg = {VERSION_KEY: FORMAT_VERSION, **names}
lay = Layout(root, names)
# Каталог задач называется ключом `dir`, если он не умолчательный: без него
# `.av-dev.toml` не сможет сказать, где искать, и разрешение уедет на
# умолчание — молча и в другой каталог.
project = conf.find_root() or Path.cwd().resolve()
cfg = dict(names)
try:
rel = root.resolve().relative_to(project).as_posix()
except ValueError:
raise Usage(f"каталог задач {root} вне проекта {project}") from None
if rel != DEFAULT_DIR:
cfg[DIR_KEY] = rel
lay = Layout(root, names, project, load_config(project))
if lay.index("backlog").exists():
raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён")
@@ -2692,12 +2706,13 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
for path, text in init_files(lay, sections, roadmap_sections, cfg).items():
plan.file(path, text)
plan.commit()
added = conf.merge_section(project, "tasks", cfg) if cfg else []
print(f"каталог задач заведён: {root}")
print(f" секции беклога: {', '.join(sections)};"
f" секции роадмапа канонические: {', '.join(roadmap_sections)}")
what = ("версия формата и имена частей записаны" if names
else "версия формата записана")
print(f" {what} в {root / CONFIG_NAME}: формат {FORMAT_VERSION}")
if added:
print(f" дописано в [tasks] {project / CONFIG_NAME}: {', '.join(added)}")
print(f" версия раскладки в {project / CONFIG_NAME}: {FORMAT_VERSION}")
return EXIT_OK
@@ -2999,7 +3014,8 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
root = Path(pl["target"])
if not dir_within_cwd(root):
raise Usage(f"target вне рабочего каталога: {root}")
lay = Layout(root, {})
project = conf.find_root() or Path.cwd().resolve()
lay = Layout(root, {}, project, load_config(project))
sections = pl.get("sections_backlog") or uniq_sections(DEFAULT_SECTIONS)
roadmap_sections = pl.get("sections_roadmap") or uniq_sections(DEFAULT_ROADMAP_SECTIONS)
known_sections = {s.lower() for s in sections}
@@ -3058,8 +3074,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
# Версия та же, что у `init`: каталог выводится из чужой раскладки сегодня и
# сегодняшним форматом, сколько бы лет ни было тому, из чего он выведен.
# Имён частей здесь нет — адаптация раскладывает всё по умолчаниям.
for path, text in init_files(lay, sections, roadmap_sections,
{VERSION_KEY: FORMAT_VERSION}).items():
for path, text in init_files(lay, sections, roadmap_sections, {}).items():
wr.file(path, text)
backlog_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("backlog")].splitlines()
roadmap_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("roadmap")].splitlines()