ревью двумя проходами: 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:
@@ -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 на любой команде,
|
||||
так что лишнее слово в этом объекте останавливает работу с задачами целиком.
|
||||
- **Секции беклога** берутся из заголовков `##` индекса как есть; их количество
|
||||
и названия — дело проекта (умолчание `ядро` / `инфра`).
|
||||
и названия — дело проекта (умолчание `ядро` / `инфра`). **В конфиге их нет** —
|
||||
второй список разошёлся бы с заголовками молча.
|
||||
|
||||
### Вызов из другого плагина
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user