приоритет стал исполнимым: move без --section и тексты, догнавшие правило 4

Механизм расстановки приоритета не запускался ни разу: --section у move был
обязательным, а все три места, где груминг его предписывает, дают команду без
него — usage error. Чиню скриптом, а не текстами: перестановка внутри секции —
самая частая операция груминга, и требовать повторить текущую секцию значит
приглашать указать не ту.

- move: --section необязателен, без него берётся секция из индекса; сообщение
  различает перестановку и перенос
- докстринги, отрицавшие правило 4 («в беклоге порядок значения не имеет»),
  приведены к действительности
- reopen ставил возвращённую строку после сырья и давал ошибку check на ровном
  месте
- edit портил написание секции в мете; корень шире — брался нижний регистр из
  разбора, а не написание заголовка. То же в close и reopen
- дыра гейта: между заведением и ready запись не судил никто. Своя строка
  здоровья check, отдельная от «готово к взятию» — она про другое
- шесть файлов и два устава обещали, что схему типа проверяет check
- раздел from-review о серьёзности стоял на «приоритетов нет»
- остатки спринта и сессии в семи местах, включая description агента формы
- индексов два, а не три; два определения порога готовности после adopt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-09 18:03:28 +03:00
co-authored by Claude Opus 5
parent c1890d9e71
commit 15dba79993
17 changed files with 181 additions and 89 deletions
+96 -28
View File
@@ -3,8 +3,9 @@
Преемник backlog.py. Разница по существу одна: **секция-как-уровень заменена
целью** (`goal:<слаг>` тегом), а приоритет стал тем, чем он и является, —
**порядком строк в беклоге**. Индексов три, и задача живёт ровно в одном из них
за раз.
**порядком строк в беклоге**. Индексов два — беклог и роадмап, — и задача живёт
ровно в одном из них за раз. `REJECTED.md` индексом не считается: он не говорит,
где запись числится, он кладбище ушедшего.
Раскладка. Путь каталога — `tasks/` в корне репозитория, жёстко. Каталог
принадлежит этому плагину, а не канону документов: `docs/` ведёт другой плагин, и
@@ -66,7 +67,7 @@ goal | feature | fix | chore | research, по-английски, как и пр
[--section S] [--goal G] [--why H] [--reason R] [--tag a,b] [--dir DIR]
tasks.py edit S [--title T] [--why H] [--type T] [--goal G]
[--add-tag a,b] [--rm-tag c,d] [--section S] [--dir DIR]
tasks.py move S --section S [--reason R] [--after S | --first] [--dir DIR]
tasks.py move S [--section S] [--reason R] [--after S | --first] [--dir DIR]
tasks.py close S (--reason R | --implemented) [--dir DIR]
tasks.py reopen S [--reason R] [--dir DIR]
tasks.py ready S [S …] [--dir DIR]
@@ -659,9 +660,10 @@ def raw_last(lines: list[str], raw: set[str]) -> list[str]:
"""Строки индекса, у которых сырьё снесено в конец своей секции.
Сырьё (`research` без раздела «Вопрос») в работу не берётся, и стоя между
берущимися оно каждый раз требует открыть файл, чтобы это понять. Порядка
«по важности» в беклоге по-прежнему нет: этот порядок **производен от
типа**, а не назначен человеком, — потому его и можно проверять машиной.
берущимися оно каждый раз требует открыть файл, чтобы это понять. Порядок
строк в беклоге — приоритет, и назначает его человек; место сырья —
единственное исключение, и оно **производно от типа**, а не назначено, —
потому его и можно проверять машиной.
Переставляются только сами строки-пункты, по своим же позициям: проза
внутри секции, отбивка и заголовки остаются на месте.
@@ -1265,6 +1267,26 @@ def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None:
else " — прочим не хватает разделов своего типа, цели или ждут"
" ответа на вопрос"))
# Схема типа — **своя** строка, а не дубль предыдущей. «Готово к взятию»
# считает только беклог и валит запись за что угодно (вопрос, отсутствие
# цели, разделы); эта называет ровно одну причину и судит **все** записи,
# включая цели, которых `ready` не смотрит вовсе. Строка нужна потому, что
# обязательность разделов проверяет только `ready` на входе в работу, а
# между заведением и взятием запись иначе не судит никто.
bad_schema = {n: t for n, t in tasks.items()
if t["type"] in TYPE_SCHEMA and schema_verdict(lay, t)[0]}
if bad_schema:
unfit = sorted(n[:-3] for n in bad_schema)
# Сырьё названо отдельно: схему оно не выполняет по определению («Вопрос»
# пуст — тем оно и сырьё), и без этой оговорки счётчик читался бы как
# число недоделанных задач, хотя часть его — записи, ещё не ставшие ими.
crude = sum(1 for t in bad_schema.values() if raw_research(lay, t))
print(f" схема типа не выполнена: {len(unfit)} из {len(tasks)}"
f" ({', '.join(unfit[:5])}{', …' if len(unfit) > 5 else ''})"
+ (f", сырья из них {crude}" if crude else "")
+ " — нет разделов, которых требует тип; чего именно, скажет"
" `tasks.py ready <слаг>`")
questions = [n for n, t in tasks.items() if questions_open(lay, t)]
if questions:
print(f" с открытым вопросом: {len(questions)}"
@@ -1483,6 +1505,15 @@ def find_section(lines: list[str], name: str) -> tuple[int | None, str]:
return None, ""
def section_at(lines: list[str], i: int) -> str:
"""Секция, в которой лежит строка `i`, — ближайший заголовок выше неё.
Дом у этого вопроса один: его задаёт и перестановка внутри секции
(`move` без `--section`), и починка «строка не в своей секции»."""
return next((m.group(1) for j in range(i, -1, -1)
if (m := SECTION.match(lines[j]))), "")
def find_entry_index(lines: list[str], slug: str) -> int | None:
for i, line in enumerate(lines):
m = INDEX_ENTRY.match(line)
@@ -1507,8 +1538,9 @@ def locate_all(lay: Layout, slug: str) -> dict[str, tuple[list[str], int]]:
def insert_entry(lines: list[str], section: str, entry: str,
after: str | None = None, first: bool = False) -> None:
"""Вставляет строку в секцию: по умолчанию в конец, --after <слаг> — следом
за указанной строкой, --first — первой. Порядок нужен только упорядоченной
части роадмапа; в беклоге он значения не имеет."""
за указанной строкой, --first — первой. Позиция значима прежде всего в
беклоге: порядок строк там и есть приоритет (правило 4), и назначает его
человек — отсюда и умолчание «в конец», а не «наверх»."""
hi, _ = find_section(lines, section)
if hi is None:
raise Usage(f"секции «{section}» в индексе нет")
@@ -1774,13 +1806,18 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
tags = [t for t in tags if not t.startswith(LEGACY_KIND_TAG)]
why = task["why"] if a.why is None else a.why
section = task["section"]
# Написание секции берётся как есть, а не в нижнем регистре: имя секции
# принадлежит **заголовку индекса**, и мета на него только ссылается (тот же
# довод, что у шага 6 `apply_fixes`). Нижний регистр уезжал бы в файл
# «Категория: ядро», а следующий `check --fix` чинил бы за собственной
# правкой. Сверка принадлежности всё равно идёт по нижнему регистру.
section = task["section_raw"]
if a.section is not None:
if old_home == new_home:
raise Usage("--section у edit — только вместе со сменой типа, меняющей"
f" индекс. Секция внутри индекса — это move (он пишет причину):"
f" tasks.py move {a.slug} --section {a.section} --reason …")
section = a.section.strip().lower()
section = a.section.strip()
# Смена типа между целью и задачей — это переезд между индексами, а не
# отказ: задача лежит ровно в одном индексе, неоднозначности нет.
@@ -1792,7 +1829,7 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
raise Usage(f"смена типа переносит строку в {lay.name(new_home)},"
f" а секции «{section}» там нет (есть: {avail}) —"
f" задай `--section <из перечисленных>`")
section = section_name.lower()
section = section_name
# Тип передаётся всегда, а не только при `--type`: у файла, не переехавшего
# на поле, он выведен из прежнего дома, и без него пересборка меты назвала
@@ -1851,6 +1888,14 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
"""Перестановка строки: внутри своей секции или в другую.
`--section` необязателен, и это не удобство. Перестановка внутри секции —
самая частая операция груминга (`move --after` и есть расстановка
приоритета), а требовать в ней повторить текущую секцию значит приглашать
указать не ту: перенос в чужую секцию выглядел бы ровно так же. Без
`--section` секция берётся из индекса — та, в которой строка уже лежит.
"""
for err in (bad_slug(a.slug), bad_reason(a.reason), bad_slug(a.after) if a.after else None):
if err:
raise Usage(err)
@@ -1865,10 +1910,17 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
f" ({', '.join(lay.name(k) for k in places)}) — неоднозначно,"
f" разбери сам: tasks.py check")
kind_index, (lines, ei) = next(iter(places.items()))
hi, section = find_section(lines, a.section)
if hi is None:
avail = ", ".join(n for _, n in section_headers(lines))
raise Usage(f"нет секции «{a.section}» в {lay.name(kind_index)} (есть: {avail})")
if a.section is None:
section = section_at(lines, ei)
if not section:
raise Usage(f"строка {a.slug} в {lay.name(kind_index)} стоит до первой"
f" секции — переставлять внутри нечего. Назови секцию:"
f" tasks.py move {a.slug} --section <секция> --reason …")
else:
hi, section = find_section(lines, a.section)
if hi is None:
avail = ", ".join(n for _, n in section_headers(lines))
raise Usage(f"нет секции «{a.section}» в {lay.name(kind_index)} (есть: {avail})")
task = parse_task(path)
new_text = meta_updated(path, section=section, reason=a.reason,
rtype=task["type"] or None)
@@ -1888,7 +1940,10 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
plan.file(path, new_text)
plan.index(lay, kind_index, lines)
plan.commit()
print(f"{a.slug}: перенесено в «{section}» ({lay.name(kind_index)})")
where = ("первой" if a.first else f"после {a.after}" if a.after else "в конец")
print(f"{a.slug}: " + (f"перенесено в «{section}»" if a.section is not None
else f"переставлено внутри «{section}»")
+ f", {where} ({lay.name(kind_index)})")
return EXIT_OK
@@ -1946,7 +2001,7 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int:
reason = a.reason.rstrip()
dot = "" if reason.endswith((".", "!", "?")) else "."
bullet = (f"- {today} `{a.slug}` — {task['title']}. Причина: {reason}{dot}"
f" Была секция: {task['section'] or ''}.")
f" Была секция: {task['section_raw'] or ''}.")
rej = lay.index("rejected")
prev = rej.read_text(encoding="utf-8") if rej.exists() else "# Ушедшее без реализации\n"
if not prev.endswith("\n"):
@@ -2039,7 +2094,9 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int:
plan.file(path, text)
target = home_index({"type": rtype})
section = tmp["section"]
# Написание — из меты как есть: имя секции уедет в доклад, а сверка
# принадлежности всё равно идёт по нижнему регистру (`find_section`).
section = tmp["section_raw"] or tmp["section"]
lines = read_lines(lay.index(target))
# Строка достигнутого снимается ДО вставки и на том же списке: иначе вторая
# правка читает индекс с диска, где первой ещё нет, и затирает её.
@@ -2058,6 +2115,16 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int:
avail = ", ".join(n for _, n in section_headers(lines))
raise Usage(f"секции «{section}» нет в {lay.name(target)} (есть: {avail})")
insert_entry(lines, sec, entry_line(lay, title, a.slug, tmp["why"]))
if target == "backlog":
# Место сырья производно от типа, и `reopen` обязан его соблюсти
# сразу: вернуть разведку без «Вопроса» просто в конец секции —
# значит поставить её после сырья, лежавшего там раньше, и получить
# ошибку `check` на ровном месте. Возвращаемого файла ещё нет на
# диске, поэтому он добавляется к набору вручную.
raw = {n for n, t in tasks_of(lay).items() if raw_research(lay, t)}
if raw_research(lay, tmp):
raw.add(f"{a.slug}.md")
lines[:] = raw_last(lines, raw)
plan.index(lay, target, lines)
elif unachieved:
plan.index(lay, target, lines)
@@ -2378,8 +2445,7 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
if ei is None:
raise RuntimeError(f"{name}: строка в {lay.name(kind)} пропала посреди"
f" прохода — чинить нечего, отчёт был бы враньём")
cur = next((m.group(1) for j in range(ei, -1, -1)
if (m := SECTION.match(idx[kind][j]))), None)
cur = section_at(idx[kind], ei)
if cur and cur.lower() != section.lower():
insert_entry(idx[kind], section, idx[kind].pop(ei))
fixed.append(f"{lay.name(kind)}: перенесена в секцию «{section}»: {name}")
@@ -3012,11 +3078,11 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
print(f" задач, не собравших разделы своего типа: {len(unfit)}"
f" check это ошибкой не считает, но `ready` их не пропустит:"
f" брать сегодня физически нечего")
print(f" закрывается порциями груминга по 5–8 задач (скилл groom):"
f" проставить цели, превратить «готово, когда» в критерии с оракулами,"
f" вынуть вопросы из прозы в раздел. Готовность к первой задаче —"
f" не «check зелёный», а «есть {CRITERIA_MIN}+ критериев хотя бы у набора"
f" под одну цель».")
print(" закрывается порциями груминга по 5–8 задач (скилл groom):"
" проставить цели, превратить «готово, когда» в критерии с оракулами,"
" вынуть вопросы из прозы в раздел. Готовность к первой задаче —"
" не «check зелёный», а «`ready` пропускает хотя бы верхние строки"
" очереди»: берут по одной, и годной обязана быть та, которую берут.")
print(" источники не удалены: сверь глазами и убери сам"
f" ({', '.join(pl.get('sources', []))}) — удалять чужое молча нельзя.")
print(" подписи ссылок машина не трогает: цель ссылки поправлена, а текст"
@@ -3069,12 +3135,14 @@ def main() -> int:
p.add_argument("--section", help="только вместе со сменой типа, меняющей индекс")
p.add_argument("--dir")
p = sub.add_parser("move", help="перенести в другую категорию беклога или часть роадмапа")
p = sub.add_parser("move", help="переставить строку: место в очереди или другая секция")
p.add_argument("slug")
p.add_argument("--section", required=True)
p.add_argument("--section", help="другая категория беклога или часть роадмапа;"
" без него — текущая секция записи")
p.add_argument("--reason")
g = p.add_mutually_exclusive_group()
g.add_argument("--after", help="встать следом за этим слагом (упорядоченная часть роадмапа)")
g.add_argument("--after", help="встать следом за этим слагом — расстановка"
" приоритета: порядок строк беклога это очередь")
g.add_argument("--first", action="store_true")
p.add_argument("--dir")