ревью двумя проходами: 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
+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