задачи: починена смена стадии, разобраны находки ревью плагина

Команда stage была дефектна по шести пунктам, и все шесть подтверждены
прогоном: не звала raw_last (переход оставлял каталог красным), не
переписывала шапку беклога (индекс продолжал объявлять прежнюю стадию),
шла в обход write_config, молча пропускала файлы с непересобираемой метой,
ломалась на беклоге без заголовков и схлопывала полки при первом
объявлении стадии.

Объявление и смена разведены: объявление беклога не трогает вовсе, смена
трогает состав секций только по явному --sections, а слить полки скрипт
не берётся ни в одном случае. Абзац шапки размечен парой «стадия», и
расхождение с конфигом стало обычным дрейфом.

Отказ по недостающей строке индекса запирал запись, пережившую упразднение
роадмапа: edit, close и reopen теперь заводят или пропускают строку сами.
Прочее: регистр stage нормализуется при чтении; --fix снимает мёртвые теги
и у неразобранных записей; move отказывает переставлять сырьё; adopt
держит место сырья; docs.py bump двигает одну запись журнала за раз;
tasks.py получил перечень упразднённых адресов, и гейт наконец видит
собственное упразднение ROADMAP.md.

Запись «Версия 3» переписана по прогону на игрушечном проекте: прежний
порядок шагов был неисполним. Закрыты дыры модели стадий (пересмотр плана
стройки стал сценарием, приёмка отвязана от груминга, from-review,
research и adopt получили развилку по стадии, перечень осей пересчитан) и
находки, старшие этой сессии: review-triage получил режим без метки, три
списка проектных копий сведены к дому с проверяемыми копиями, пять
пересказов правил стали помеченными копиями или ссылками, language.md
перестал объявлять юрисдикцию над чужим плагином.
This commit is contained in:
av
2026-08-13 15:08:29 +03:00
parent 8d8c1656e5
commit ed83ec7dc0
28 changed files with 678 additions and 259 deletions
+33 -3
View File
@@ -190,7 +190,9 @@ stateDiagram-v2
Вместе с целью ушли: секция `Готово` (её ответ дают спеки и история), теги
`goal:<слаг>` и `decomposed`, поле меты `Секция` (осталась `Категория`), раздел
`Завершение` и команды `list --goal`, `edit --goal`. Встретились в проекте —
`Завершение`, ключи `[tasks] roadmap` и `[tasks] completion_heading`, флаги
`add --goal`, `list --goal`, `list --index`, `edit --goal`, `edit --section` и
`init --roadmap`. Встретились в проекте —
`check` назовёт их поимённо, `check --fix` снимет теги, а запись типа `goal`
оставит человеку: во что она превращается — в задачу или в ничто, — машина не
решает.
@@ -517,7 +519,9 @@ python3 $tk adopt scan --from … --stage S | apply --plan … # разова
пять файлов из одной мысли, а сорок файлов из сорока сырых находок. Защита та
же, что в самом ревью: кластеризация по причине, дедуп против живых и
`REJECTED.md`, находка без свидетельства → сырьё (`research`), а не задача, и карта кластеров
пользователю до создания файлов. Порядок и отображение серьёзности —
пользователю до создания файлов. **Стадию смотри и здесь**, тем же нулевым
шагом: от неё зависит, куда ляжет тяжёлая находка — наверх очереди или после
своей зависимости. Порядок и отображение серьёзности —
[references/from-review.md](references/from-review.md).
### Прийти в репозиторий, где задачи уже как-то ведутся
@@ -530,6 +534,32 @@ python3 $tk adopt scan --from … --stage S | apply --plan … # разова
Если переводить надо не только задачи, а весь `docs/` — это скилл
`av-dev:canon`, и он зовёт этот сценарий сам на своём шаге.
### Пересмотр плана стройки
Операция стадии `build`, и на доработке её нет: там переоценка идёт порциями и
называется грумингом. **Повод один — сменился замысел**, а не «давно не
смотрели»: план стройки протухает не по частям, а целиком, потому что порядок в
нём — зависимость, и одна изменившаяся посылка переставляет всё, что ниже.
Порционный разбор здесь вреден, и это не вкус: вынуть пять шагов из середины
списка, порядок которого и есть его содержание, — значит получить план, про
который никто уже не скажет, почему он такой.
1. **Назови, что изменилось в замысле.** Одной фразой, и она уедет причиной в
каждое движение. Не находится — значит повода нет, и пересмотр не нужен.
2. **Прочитай список целиком**, сверху вниз, и по каждой строке ответь одно из
трёх: остаётся как есть, переезжает (`move --after` с причиной), уходит
(`close --reason`). Дописанное новое встаёт туда, куда велит зависимость, а
не в конец.
3. **Покажи человеку весь новый список**, а не отдельные решения: план читается
только целиком. `AskUserQuestion` с готовым порядком и доводом на каждое
движение.
4. `check` и доклад: сколько строк тронуто из скольких, что ушло и почему.
**Границу с грумингом держи твёрдо.** Если хочется пересмотреть план «потому что
накопилось» — это не пересмотр, а признак того, что стройка кончилась: беклог
перестал быть планом и стал очередью. Проверь `stage`.
### Декомпозиция и штурм сырья
[references/split.md](references/split.md). Обе операции превращают одну запись в
@@ -557,7 +587,7 @@ python3 $tk adopt scan --from … --stage S | apply --plan … # разова
одну половину делает дорогой, а вторую — поверхностной.
Модель у обоих одна, `sonnet`, и это не отменяет разреза. Оба судят по
**записанному правилу**семь пунктов формы против правил языка, — а их находка
**записанному правилу**шесть пунктов формы против правил языка, — а их находка
приходит готовой формулировкой, которую читает и отклоняет человек, а не молча
реализует оркестратор. Ошибка здесь стоит строки чтения, и платить за неё верхней
моделью не за что.
+4 -3
View File
@@ -100,9 +100,10 @@ python3 $tk adopt apply --plan tasks-adopt-plan.json \
`apply` печатает состояние по факту: сколько задач не собрало разделы своего типа
(для `check` это не ошибка, а строка здоровья, но `ready` такую задачу не
пропустит). Закрывается это **порциями груминга** — скилл `groom`, 58 задач за
порцию: превратить «готово, когда» в критерии с оракулами, вынуть вопросы из
прозы в раздел «Вопросы».
пропустит). Закрывается это **порциями по 58 задач**: превратить «готово,
когда» в критерии с оракулами, вынуть вопросы из прозы в раздел «Вопросы». На
доработке это груминг (скилл `av-dev:task-groom`), на стройке — гигиена полей
этого скилла: груминга там нет.
**Порядок строк проверяется глазами отдельно.** На стройке он выведен из
нумерации источника, и там, где её не было, он случаен. На доработке машина
@@ -83,6 +83,14 @@
[скилле груминга](../../task-groom/SKILL.md#приоритет-как-его-расставляют), и
серьёзность попадает ровно в один из них.
**Всё это — про доработку.** На стройке порядок строк значит зависимость, и
`--first` там означает «ни от чего не зависит», а не «важнее всех»: находка,
поднятая наверх, встанет перед собственной зависимостью. Место находке на стройке
называет зависимость — `move --after <шаг, после которого её можно делать>`, — а
серьёзность идёт **причиной в мете** и разбирается ближайшим пересмотром плана
(`task-track`, «Пересмотр плана стройки»). Груминга там нет, и откладывать «до
него» некуда.
- **тяжёлая находка со свидетельством о сломанном сейчас** → задача
**первой строкой секции**: `move <слаг> --first
--reason «сломано сейчас: …»`. Это довод «что сломано сейчас» из перечня
@@ -110,10 +110,13 @@
| поле **Хук** | поле **Зачем** |
| мета одной строкой через `·` | мета списком, поле на строку |
Единственное, чего `--fix` не делает сам, — **проставить тип записи, у которой
его неоткуда взять**: `feature` от `chore` машина не отличает, и подставленное
наугад значение врало бы ровно там, где по нему принимают решение. Такие записи
он называет поимённо пометкой `НЕОДНОЗНАЧНО`.
Чего `--fix` не делает сам — **решает за человека, каким быть типу**. Случаев
три, и все три уезжают пометкой `НЕОДНОЗНАЧНО`: тип, которого неоткуда взять
(`feature` от `chore` машина не отличает); тип вне словаря; и запись типа `goal`
— целей больше нет, а во что превращается эта, в задачу или в ничто, машина не
знает. Подставленное наугад значение врало бы ровно там, где по нему принимают
решение. Мёртвые теги и имя поля места при этом снимаются у **любой** записи,
включая ту, чей тип остался неразобранным.
### Затрагивает
@@ -42,7 +42,8 @@
| Место в секции беклога | **конец**, `check --fix` сносит туда сам | среди прочих |
| `tasks.py list --raw` | показывает | нет |
Порядок строк в беклоге назначает человек — это приоритет (правило 4 скилла).
Порядок строк в беклоге назначает человек, и стадия решает, что он значит:
зависимость на стройке, важность на доработке (правило 4 скилла).
Место сырья **из него изъято**: оно производно от типа и заполненности, а не от
чьего-то решения, и потому его проверяет и чинит машина. Приоритетом оно не
становится: сырьё не берут вовсе, и место в конце говорит именно это.
+324 -125
View File
@@ -189,6 +189,16 @@ DEFAULTS = {
# с диском первым делом, иначе кривой ключ выглядит как пропавший файл).
PATH_KEYS = ("items", "backlog", "rejected")
# Упразднённые части каталога — с адресом, куда уехало содержимое. Перечень
# читает `scripts/addresses.py`: адрес, названный в чужой прозе, опровергается
# перечнем владельца, а не памятью. Без этой константы упразднение, сделанное
# здесь, не ловилось бы гейтом вовсе — то есть шаг гейта молчал бы ровно про то,
# ради чего заведён.
RETIRED = {
"ROADMAP.md": "→ BACKLOG.md: роадмап упразднён вместе с типом goal",
"SPRINT.md": "→ порядок строк беклога (спринты отменены)",
}
# Умолчания секций по стадиям. На доработке это **полки домена**: смысла они не
# несут, называет их проект. На стройке секция ровно одна — список от базы к
# деталям, — и её имя тоже дело проекта: различать ей нечего, она одна.
@@ -262,6 +272,10 @@ BULLET = re.compile(r"^[-*]\s+(.*)$")
REJECTED_ENTRY = re.compile(r"^- \d{4}-\d{2}-\d{2} `[a-z0-9-]+` — .+")
RESEARCH = "research"
# Прежний дом направления. Тип `goal` упразднён вместе с роадмапом; читается
# только затем, чтобы `check` назвал его вслух, а `--fix` снял его следы.
LEGACY_GOAL_TAG = "goal:"
LEGACY_GOAL = "goal"
# Тип — ось записи и **закрытый словарь**. Открытый разъедется на синонимах
# (`bug`, `bugfix`, `fix`, `defect`), и отбор по типу перестанет отвечать на
# свой единственный вопрос. Ни один тип не подходит — это сигнал, что в записи
@@ -272,17 +286,16 @@ TYPES = ("feature", "fix", "chore", RESEARCH)
# дословно, — так тип виден там, где решают «брать или не брать», и инвариант
# «заголовок в индексе дословно» остаётся нетронутым.
TYPE_EMOJI = {"feature": "", "fix": "🐞", "chore": "🧹", RESEARCH: "🔬"}
EMOJI_TYPE = {v: k for k, v in TYPE_EMOJI.items()}
# Эмодзи упразднённого типа читается по-прежнему — иначе она не **снимается**:
# заголовок разбирается на «значок + текст», и незнакомый значок уезжает в текст,
# а следующая правка типа ставит второй перед первым («✨ 🎯 …»).
EMOJI_TYPE = {v: k for k, v in TYPE_EMOJI.items()} | {"🎯": LEGACY_GOAL}
TAKEABLE = TYPES # берутся в работу все четыре: целей больше нет
# Заголовок в форме действия требуется там, где исход работы — изменение
# системы. У разведки он называет предмет: её исход знание, и заголовок-действие
# обещал бы решённость, которой ещё нет.
ACTION_TYPES = ("feature", "fix", "chore")
QUESTION_TAG = "question"
# Прежний дом направления. Тип `goal` упразднён вместе с роадмапом; тег
# читается только затем, чтобы `check` назвал его вслух, а `--fix` снял.
LEGACY_GOAL_TAG = "goal:"
LEGACY_GOAL = "goal"
# Схема тела на тип: какие разделы обязательны, какие ещё допустимы. Значения —
# **ключи конфига**, а не сами заголовки: имена заголовков проект настраивает,
@@ -458,7 +471,12 @@ class Layout:
# Стадия — не имя части, поэтому и не в `cfg`. Пустая строка значит «не
# объявлена», и это отдельное состояние: без неё непонятно, что значит
# порядок строк, и `check` об этом говорит.
self.stage = cfg.get(STAGE_KEY, "")
#
# Приводится к нижнему регистру ровно потому, что к нему же приводит
# валидация: `stage = "Build"` проходил её и не совпадал ни с одним
# значением здесь, так что каждое ветвление молча уходило в ветку
# «стадии нет» — при зелёном конфиге и объявленной стадии.
self.stage = str(cfg.get(STAGE_KEY, "")).strip().lower()
def index(self, kind: str) -> Path:
return self.root / self.cfg[kind]
@@ -1145,6 +1163,7 @@ def check(lay: Layout, fix: bool = False) -> int:
raw_names = {n for n, t in tasks.items() if raw_research(lay, t)}
errors += sections_verdict(lay, sections, label)
errors += stage_block_verdict(lay, lines, label)
for s in sections:
if s.lower() in BLOCKER_SECTIONS:
notes.append(f"{label}: секции «{s}» быть не должно —"
@@ -1457,6 +1476,12 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int:
for t in rows:
t["touched"] = dates.get(t["path"].name, "")
rows.sort(key=lambda t: (t["touched"] == "", t["touched"]))
# Отбор работает на любой стадии, но значит он разное, и молчать об этом
# нельзя: на стройке шаг лежит долго законно — до него не дошла очередь,
# и он стоит там, где стоит, по зависимости.
if lay.stage == BUILD:
print(" стройка: залежалость здесь не мера — шаг ждёт своей"
" очереди по зависимости, а не потому, что его обходят\n")
else:
rows.sort(key=lambda t: (order.get(t["section"], 99), t["path"].name))
@@ -1700,8 +1725,8 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int:
section = a.section or (section_headers(lines)[0][1] if section_headers(lines) else "")
hi, section = find_section(lines, section)
if hi is None:
avail = ", ".join(n for _, n in section_headers(lines))
raise Usage(f"нет секции «{a.section}» в {lay.name('backlog')} (есть: {avail})")
avail = ", ".join(n for _, n in section_headers(lines)) or "ни одной"
raise Usage(f"нет секции «{section}» в {lay.name('backlog')} (есть: {avail})")
tags = split_tags(a.tag)
if QUESTION_TAG in tags:
@@ -1766,9 +1791,6 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
if not path.exists():
raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/")
place = locate(lay, a.slug)
if place is None:
raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix")
task = parse_task(path)
if a.title is not None and not a.title.strip():
raise Usage("пустой заголовок")
@@ -1802,6 +1824,21 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
# правкой. Сверка принадлежности всё равно идёт по нижнему регистру.
section = task["section_raw"]
# Строки индекса может не быть, и это не всегда поломка: запись, пережившая
# упразднение своего индекса, лежит файлом без строки, а `check --fix`
# восстановить её не может — секция в мете указывает на исчезнувшую полку.
# Отказ здесь запирал бы такую запись навсегда: строку не восстановить, пока
# не сменишь тип, и тип не сменить, пока нет строки. Заводим строку сами, в
# первую секцию, и говорим об этом.
lost_section = ""
if place is None:
heads = section_headers(read_lines(lay.index("backlog")))
if not heads:
raise Usage(f"строки для {a.slug} нет, и в {lay.name('backlog')} нет"
f" ни одной секции — заводить её некуда")
if find_section(read_lines(lay.index("backlog")), section)[0] is None:
lost_section, section = section or "", heads[0][1]
# Тип передаётся всегда, а не только при `--type`: у файла, не переехавшего
# на поле, он выведен из прежнего дома, и без него пересборка меты потеряла
# бы его вовсе.
@@ -1823,8 +1860,12 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
if rtype == RESEARCH and not task["body"].get(lay.cfg["question_heading"].lower()):
raw_now.add(f"{a.slug}.md")
lines, ei = place
lines[ei] = entry_line(lay, h1, a.slug, why)
if place is None:
lines = read_lines(lay.index("backlog"))
insert_entry(lines, section, entry_line(lay, h1, a.slug, why))
else:
lines, ei = place
lines[ei] = entry_line(lay, h1, a.slug, why)
plan = Plan()
plan.file(path, new_text)
@@ -1835,6 +1876,11 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
("теги", a.add_tag or a.rm_tag))
if v is not None]
print(f"{a.slug}: обновлено ({', '.join(changed)})")
if place is None:
print(f" строки в {lay.name('backlog')} не было — заведена в секции"
f" «{section}», в конец: позицию назначает человек"
+ (f" (секции «{lost_section}» из меты в индексе нет)"
if lost_section else ""))
if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"]:
print(f" вопрос открыт — в работу задача не берётся, пока он не разобран"
f" (`tasks.py ready {a.slug}` это и скажет)")
@@ -1844,11 +1890,16 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
"""Перестановка строки: внутри своей секции или в другую.
**Что значит перестановка, говорит стадия, а не эта команда**: на стройке она
называет зависимость («этот шаг делается после того»), на доработке —
приоритет («это берут раньше»). Отсюда и `--reason`: причина у двух движений
разная, и через месяц её не восстановить.
`--section` необязателен, и это не удобство. Перестановка внутри секции —
самая частая операция груминга (`move --after` и есть расстановка
приоритета), а требовать в ней повторить текущую секцию значит приглашать
указать не ту: перенос в чужую секцию выглядел бы ровно так же. Без
`--section` секция берётся из индекса — та, в которой строка уже лежит.
самая частая операция и там и там, а требовать в ней повторить текущую секцию
значит приглашать указать не ту: перенос в чужую секцию выглядел бы ровно так
же. Без `--section` секция берётся из индекса — та, в которой строка уже
лежит.
"""
for err in (bad_slug(a.slug), bad_reason(a.reason), bad_slug(a.after) if a.after else None):
if err:
@@ -1873,6 +1924,15 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
avail = ", ".join(n for _, n in section_headers(lines))
raise Usage(f"нет секции «{a.section}» в {label} (есть: {avail})")
task = parse_task(path)
# Место сырья производно от типа, а не назначается: назначить его — значит
# получить дрейф, который следующий же `check --fix` отменит, стерев решение
# человека. Поэтому отказ, и с названным выходом: сырьё перестаёт быть
# сырьём, как только у него появляется «Вопрос».
if (a.after or a.first) and raw_research(lay, task):
raise Usage(f"{a.slug} — сырьё (`{RESEARCH}` без раздела"
f" «{lay.cfg['question_heading']}»), и место у него не"
f" назначается: конец секции, потому что его не берут."
f" Допиши «{lay.cfg['question_heading']}» — и переставляй")
new_text = meta_updated(path, section=section, reason=a.reason,
rtype=task["type"] or None)
if new_text is None:
@@ -1883,9 +1943,12 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
insert_entry(lines, section, entry, a.after, a.first)
except KeyError as e:
raise Usage(f"--after {a.after}: такой строки в секции «{section}» нет") from e
if not (a.after or a.first):
lines[:] = raw_last(lines, {n for n, t in tasks_of(lay).items()
if raw_research(lay, t)})
# Сырьё сносится в конец **всегда**, в том числе после `--after`/`--first`:
# переставленная строка от этого не двигается (она не сырьё — отказ выше),
# а вот сырьё, оказавшееся выше неё, встаёт на своё место сразу, а не до
# ближайшего `check --fix`.
lines[:] = raw_last(lines, {n for n, t in tasks_of(lay).items()
if raw_research(lay, t)})
plan = Plan()
plan.file(path, new_text)
@@ -1905,9 +1968,10 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int:
path = lay.items / f"{a.slug}.md"
if not path.exists():
raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/")
# Строки может не быть, и отказ здесь запирал бы запись навсегда: чтобы
# строку восстановить, надо её куда-то класть, а класть незачем — запись
# закрывают. Так закрывается и то, что пережило упразднение своего индекса.
place = locate(lay, a.slug)
if place is None:
raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix")
task = parse_task(path)
plan = Plan()
@@ -1922,13 +1986,16 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int:
if not prev.endswith("\n"):
prev += "\n"
plan.file(rej, prev + bullet + "\n")
lines, ei = place
lines.pop(ei)
plan.index(lay, "backlog", lines)
if place is not None:
lines, ei = place
lines.pop(ei)
plan.index(lay, "backlog", lines)
plan.delete(path)
plan.commit()
print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}")
if place is None:
print(f" строки в {lay.name('backlog')} не было — удалён только файл")
if not a.reason:
print(" дорога назад: файл восстанавливается из git —"
f" `tasks.py reopen {a.slug} --reason «приёмка не сошлась: …»`")
@@ -1987,19 +2054,32 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int:
else:
text = upd
tmp = parse_task_text(text, path)
plan = Plan()
plan.file(path, text)
# Написание — из меты как есть: имя секции уедет в доклад, а сверка
# принадлежности всё равно идёт по нижнему регистру (`find_section`).
section = tmp["section_raw"] or tmp["section"]
lines = read_lines(lay.index("backlog"))
heads = section_headers(lines)
lost_section = ""
if find_section(lines, section)[0] is None:
# Секции могло не стать законно: смена стадии переразмечает беклог, и
# задача, закрытая до перехода, ссылается на исчезнувшую полку. Отказ
# здесь означал бы, что закрытое до перехода не возвращается никогда, —
# а `reopen` заведён ровно на случай, когда приёмка не сошлась. Кладём в
# первую секцию, правим мету и говорим об этом вслух.
if not heads:
raise Usage(f"в {lay.name('backlog')} нет ни одной секции —"
f" возвращать некуда")
lost_section, section = section, heads[0][1]
rebuilt = meta_rebuilt(text.splitlines(), section=section)
if rebuilt is not None:
text = "\n".join(rebuilt) + "\n"
tmp = parse_task_text(text, path)
plan = Plan()
plan.file(path, text)
if find_entry_index(lines, a.slug) is None:
hi, sec = find_section(lines, section)
if hi is None:
avail = ", ".join(n for _, n in section_headers(lines))
raise Usage(f"секции «{section}» нет в {lay.name('backlog')} (есть: {avail})")
insert_entry(lines, sec, entry_line(lay, title, a.slug, tmp["why"]))
insert_entry(lines, section, entry_line(lay, title, a.slug, tmp["why"]))
# Место сырья производно от типа, и `reopen` обязан его соблюсти сразу:
# вернуть разведку без «Вопроса» просто в конец секции — значит
# поставить её после сырья, лежавшего там раньше, и получить ошибку
@@ -2028,8 +2108,11 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int:
# а его назначает человек. Молча вернуть задачу наверх очереди значило бы
# принять за него решение, которого он не принимал.
print(f"{a.slug}: возвращён в {lay.name('backlog')} из истории git"
f" (секция «{section}», в конец: позиция это приоритет, и её"
f" назначает человек)")
f" (секция «{section}», в конец: позицию назначает человек)")
if lost_section:
print(f" секции «{lost_section}» в беклоге больше нет — положен в"
f" «{section}», «{PLACE_KEY}» в файле поправлена. Так бывает после"
f" смены стадии: состав секций там переразмечается")
for line in removed:
print(f" снята строка {lay.name('rejected')}: {line.strip()}")
print(" сверь тело: оно восстановлено на момент удаления, всё позднейшее"
@@ -2181,6 +2264,37 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
# принимают решение.
for name, task in tasks.items():
rtype = task["type"]
# Мёртвые теги и прежнее имя поля места снимаются у **любой** записи,
# включая ту, чей тип машина исправить не берётся. Тег `goal:` мёртв
# независимо от того, что за запись его несёт, а «Секция» — прежнее имя
# того же самого поля. Стой эта правка после разбора типа, перевод
# проекта оставлял бы их ровно в файлах целей — то есть в тех, которые
# человек как раз и разбирает руками, и разбирал бы он их с мусором.
want_key = PLACE_KEY.lower()
tags = [t for t in task["tags"]
if not t.startswith((LEGACY_KIND_TAG, LEGACY_GOAL_TAG))
and t != LEGACY_DECOMPOSED_TAG]
renamed = bool(task["place_key"]) and task["place_key"] != want_key
retyped = bool(rtype) and rtype in TYPES and task["meta_type"] != rtype
if tags != task["tags"] or renamed or retyped:
if not stage(task, rtype=rtype if retyped else None,
tags=tags if tags != task["tags"] else None):
ambiguous.append(f"{name}: чинить мету некуда — в файле нет"
f" мета-блока")
else:
what = []
if retyped:
what.append(f"тип «{rtype}» в поле **Тип:**")
if task["legacy_kind"]:
what.append(f"снят тег {LEGACY_KIND_TAG}{task['legacy_kind']}")
if task["legacy_goal"]:
what.append(f"снят тег {task['legacy_goal']}")
if LEGACY_DECOMPOSED_TAG in task["tags"]:
what.append(f"снят тег {LEGACY_DECOMPOSED_TAG}")
if renamed:
what.append(f"поле места → «{PLACE_KEY}»")
fixed.append(f"{name}: " + ", ".join(what))
if not rtype:
ambiguous.append(f"{name}: тип не выводится — нет ни поля **Тип:**, ни"
f" тега {LEGACY_KIND_TAG}<род>, ни префикса заголовка."
@@ -2197,29 +2311,6 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
f" ({', '.join(TYPES)}) — чем он заменяется,"
f" решает человек")
continue
want_key = PLACE_KEY.lower()
# Прежние теги снимаются здесь же: у обоих не осталось того, на что они
# указывали, — целей нет, а разбирать нечего.
tags = [t for t in task["tags"]
if not t.startswith((LEGACY_KIND_TAG, LEGACY_GOAL_TAG))
and t != LEGACY_DECOMPOSED_TAG]
renamed = bool(task["place_key"]) and task["place_key"] != want_key
if task["meta_type"] != rtype or tags != task["tags"] or renamed:
if not stage(task, rtype=rtype,
tags=tags if tags != task["tags"] else None):
ambiguous.append(f"{name}: тип «{rtype}» переносить некуда —"
f" в файле нет мета-блока")
else:
what = [f"тип «{rtype}» в поле **Тип:**"]
if task["legacy_kind"]:
what.append(f"снят тег {LEGACY_KIND_TAG}{task['legacy_kind']}")
if task["legacy_goal"]:
what.append(f"снят тег {task['legacy_goal']}")
if LEGACY_DECOMPOSED_TAG in task["tags"]:
what.append(f"снят тег {LEGACY_DECOMPOSED_TAG}")
if renamed:
what.append(f"поле места → «{PLACE_KEY}»")
fixed.append(f"{name}: " + ", ".join(what))
want_h1 = h1_of(rtype, task["bare"])
if task["title"] and task["title"] != want_h1:
src = staged_lines(task)
@@ -2287,11 +2378,14 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
insert_entry(idx[kind], section, entry_line(lay, task["title"], name[:-3],
task["why"]))
# Восстановленная строка встаёт в конец секции, и это надо сказать:
# позиция в беклоге — приоритет, а его назначает человек. Молчаливое
# восстановление выдало бы машинную позицию за его решение.
# позицию назначает человек, а машинная выдала бы себя за его
# решение. Чем именно она была бы — зависимостью или приоритетом, —
# решает стадия, и назвать её тут дешевле, чем заставлять вспоминать.
means = ("зависимость" if lay.stage == BUILD else
"приоритет" if lay.stage == SUPPORT else "порядок работ")
fixed.append(f"{lay.name(kind)}: восстановлена строка {name}"
" — в конце секции, позицию назначь сам:"
" порядок строк это приоритет"
f" — в конце секции, позицию назначь сам:"
f" порядок строк это {means}"
+ ("" if task["why"] else " (в файле нет «зачем» — допиши)"))
dirty.add(kind)
continue
@@ -2311,6 +2405,13 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
# имеет; тем более он не сливает их на стройке — в каком порядке пойдут
# строки слитых полок, знает только человек.
for kind, lines in idx.items():
if kind == "backlog" and lay.stage in STAGES:
span = stage_block_span(lines)
if span is not None and lines[span[0]:span[1]] != stage_block(lay.stage):
lines[span[0]:span[1]] = stage_block(lay.stage)
fixed.append(f"{lay.name(kind)}: шапка переписана под стадию"
f" «{STAGE_RU[lay.stage]}»")
dirty.add(kind)
raw = {n for n, t in tasks.items() if raw_research(lay, t)}
if (moved := raw_last(lines, raw)) != lines:
lines[:] = moved
@@ -2360,9 +2461,50 @@ BACKLOG_ORDER = {
"одной, по мере появления; пустой беклог — нормальное состояние.",
}
# Абзац шапки, объявляющий стадию, размечен парой комментариев — и это не
# украшение. Стадия решает, что значит порядок строк, а читают об этом **здесь**:
# индекс открывают вместо документации. Без разметки `stage` не знал бы, что
# переписывать, и абзац продолжал бы называть прежнюю стадию — молча и навсегда.
# С разметкой расхождение шапки с конфигом становится обычным дрейфом: `check`
# его называет, `check --fix` правит.
STAGE_OPEN = "<!-- стадия -->"
STAGE_CLOSE = "<!-- /стадия -->"
def init_files(lay: Layout, sections: list[str], stage_name: str,
cfg: dict) -> dict[Path, str]:
def stage_block(stage_name: str) -> list[str]:
return [STAGE_OPEN,
f"Стадия проекта — **{STAGE_RU[stage_name]}**"
f' (`[tasks] {STAGE_KEY} = "{stage_name}"`).',
BACKLOG_ORDER[stage_name],
STAGE_CLOSE]
def stage_block_span(lines: list[str]) -> tuple[int, int] | None:
"""Границы размеченного абзаца — `[начало, конец)`. None, если разметки нет."""
try:
start = next(i for i, ln in enumerate(lines) if ln.strip() == STAGE_OPEN)
end = next(i for i in range(start + 1, len(lines))
if lines[i].strip() == STAGE_CLOSE)
except StopIteration:
return None
return start, end + 1
def stage_block_verdict(lay: Layout, lines: list[str], label: str) -> list[str]:
"""Шапка беклога против конфига. Разметки нет — молчим: индекс мог быть
заведён до её появления, и требовать её от чужого файла не за что."""
span = stage_block_span(lines)
if span is None or lay.stage not in STAGES:
return []
want = "\n".join(stage_block(lay.stage))
if "\n".join(lines[span[0]:span[1]]).strip() == want.strip():
return []
return [f"{label}: шапка объявляет не ту стадию, что конфиг"
f" ({STAGE_RU[lay.stage]}) — а читают о смысле порядка строк"
f" именно её; перепишет `check --fix`"]
def init_files(lay: Layout, sections: list[str], stage_name: str) -> dict[Path, str]:
out: dict[Path, str] = {}
# Служебный файл здесь не заводится: его пишет `write_config` по живому
# файлу — версию двигает построчно, ключи дописывает, чужого не затирает.
@@ -2372,9 +2514,7 @@ def init_files(lay: Layout, sections: list[str], stage_name: str,
"# Беклог\n\n"
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
"+ строка здесь. Ведётся скиллом `av-dev:task-track`.\n\n"
f"Стадия проекта — **{STAGE_RU[stage_name]}** (`[tasks] {STAGE_KEY} ="
f' "{stage_name}"`).\n'
+ BACKLOG_ORDER[stage_name] + "\n\n"
+ "\n".join(stage_block(stage_name)) + "\n\n"
f"Одно место в очереди назначено не человеком, а типом: сырьё"
f" (`{RESEARCH}`\nбез раздела «{lay.cfg['question_heading']}») стоит в"
" конце секции — его не берут.\n\n"
@@ -2491,7 +2631,7 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
lay.items.mkdir(parents=True, exist_ok=True)
plan = Plan()
for path, text in init_files(lay, sections, stage_name, cfg).items():
for path, text in init_files(lay, sections, stage_name).items():
plan.file(path, text)
plan.commit()
said = write_config(project, cfg)
@@ -2507,17 +2647,17 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
# --- stage: смена стадии проекта ---
def cmd_stage(lay: Layout, a: argparse.Namespace) -> int:
"""Показать стадию или сменить её.
"""Показать стадию, объявить её впервые или сменить.
Смена — событие, а не настройка: она переворачивает смысл порядка строк
(зависимость ↔ важность) и состав секций. Записывается она ключом конфига,
и датой ей служит коммит — отдельного журнала для одной строки заводить не
стоит.
**Объявление и смена — разные операции, и различает их не флаг, а факт:**
была ли стадия названа раньше. Объявление беклога не трогает вовсе — оно
называет то, что уже верно, и переразметить при этом чужие полки значило бы
подменить ответ на вопрос вопросом о нём. Смена трогает шапку и конфиг, а
состав секций — **только если её об этом попросили** `--sections`.
Остаток беклога переезжает в первую новую секцию **целиком и молча не
остаётся**: строки, писавшиеся как план, на доработке продолжают жить
задачами, но порядок их с этого момента значит другое, и об этом говорится
вслух.
Слить полки сам скрипт не берётся ни в одном из случаев (решение Р240): в
каком порядке пойдут строки слитых полок, знает человек. Отсюда и отказ на
стройке при нескольких секциях — с названным выходом, а не глухой.
"""
if a.to is None:
print(f"стадия: {STAGE_RU.get(lay.stage, 'не объявлена')}"
@@ -2528,54 +2668,107 @@ def cmd_stage(lay: Layout, a: argparse.Namespace) -> int:
want = a.to.strip().lower()
if want == lay.stage:
raise Usage(f"стадия уже «{want}» — менять нечего")
sections = uniq_sections(a.sections or DEFAULT_SECTIONS[want])
if not sections:
raise Usage("пустой список секций")
if want == BUILD and len(sections) > 1:
raise Usage(f"на стройке секция одна, а названо {len(sections)}"
f" ({', '.join(sections)})")
raise Usage(f"стадия уже «{STAGE_RU[want]}» ({want}) — менять нечего")
declaring = lay.stage not in STAGES
lines = read_lines(lay.index("backlog"))
heads = section_headers(lines)
# Тело каждой секции — всё, что под её заголовком: и строки-пункты, и проза.
# Собирается оно в первую новую секцию в прежнем порядке секций: другого
# порядка машина не знает, а выдумать его значило бы переставить чужую
# очередь.
head = lines[:heads[0][0]] if heads else lines
body: list[str] = []
for k, (i, _) in enumerate(heads):
end = heads[k + 1][0] if k + 1 < len(heads) else len(lines)
body += lines[i + 1:end]
moved = sum(1 for line in body if INDEX_ENTRY.match(line))
if not heads:
raise Usage(f"в {lay.name('backlog')} нет ни одной секции — стадию"
f" объявлять не над чем. Заведи секцию заголовком «## …»"
f" и повтори")
current = [name for _, name in heads]
out = [*head]
for k, s in enumerate(sections):
out.append(f"## {s}")
if k == 0:
out += body
if a.sections is not None and declaring:
raise Usage("--sections при объявлении стадии не принимается: объявление"
" называет то, что уже верно, и беклог не переразмечает."
" Секции меняет `move <слаг> --section <секция> --reason …`")
sections = uniq_sections(a.sections) if a.sections is not None else current
if not sections:
raise Usage("пустой список секций")
if want == BUILD and len(sections) > 1:
raise Usage(
f"на стройке беклог — один список от базы к деталям, а секций"
f" {len(sections)}: {', '.join(sections)}. Слить их машина не берётся —"
f" порядок строк в слитом списке знает только человек. Либо слей сам"
f" (`move <слаг> --section <куда> --reason …`) и повтори, либо назови"
f" итоговую секцию явно: `stage {BUILD} --sections <имя>`"
+ (" (при объявлении стадии этот флаг не принимается —"
" сперва слей руками)" if declaring else ""))
merge = sections != current
plan = Plan()
plan.index(lay, "backlog", out)
# Категория в файлах — производна от заголовка индекса, и разъехаться ей
# нельзя: `check` назовёт это дрейфом на первой же записи.
retyped = 0
for task in tasks_of(lay).values():
if task["section_raw"] == sections[0]:
continue
text = meta_updated(task["path"], section=sections[0],
rtype=task["type"] or None)
if text is None:
continue
plan.file(task["path"], text)
retyped += 1
plan.commit()
conf.set_section_key(lay.project, "tasks", STAGE_KEY, want)
skipped: list[str] = []
moved, retyped = 0, 0
was = STAGE_RU.get(lay.stage, "не объявлена")
print(f"стадия: {was}{STAGE_RU[want]}")
print(f" секции беклога: {', '.join(sections)};"
f" перенесено строк {moved}, поправлена «{PLACE_KEY}» у {retyped} файлов")
if want == SUPPORT:
if merge:
# Тело каждой секции — всё, что под её заголовком: и строки-пункты, и
# проза. Собирается оно в первую новую секцию в прежнем порядке секций:
# другого порядка машина не знает, а выдумать его значило бы переставить
# чужую очередь.
head = lines[:heads[0][0]]
body: list[str] = []
for k, (i, _) in enumerate(heads):
end = heads[k + 1][0] if k + 1 < len(heads) else len(lines)
body += lines[i + 1:end]
moved = sum(1 for line in body if INDEX_ENTRY.match(line))
lines = [*head]
for k, s in enumerate(sections):
lines.append(f"## {s}")
if k == 0:
lines += body
# Категория в файлах производна от заголовка индекса, и разъехаться ей
# нельзя: `check` назовёт это дрейфом на первой же записи.
for name, task in tasks_of(lay).items():
if task["section_raw"] == sections[0]:
continue
text = meta_updated(task["path"], section=sections[0],
rtype=task["type"] or None)
if text is None:
skipped.append(name) # мета не пересобирается — скажем вслух
continue
plan.file(task["path"], text)
retyped += 1
# Шапка объявляет стадию, и читают о смысле порядка строк именно её. Не
# переписать её значило бы оставить в индексе прямое враньё.
span = stage_block_span(lines)
if span is not None:
lines[span[0]:span[1]] = stage_block(want)
# Место сырья производно от типа, и слияние секций его нарушает: сырьё из
# второй полки оказывается в середине списка. Без этого шага переход
# оставлял бы каталог красным на ровном месте.
plan.index(lay, "backlog", raw_last(lines, {n for n, t in tasks_of(lay).items()
if raw_research(lay, t)}))
plan.commit()
# Конфиг: файл есть — правим ключ, файла нет — заводим скелетом. Иначе
# объявление стадии в проекте без `.av-dev.toml` рождало бы конфиг без
# версии раскладки, то есть меняло один отказ `check` на другой.
if (lay.project / CONFIG_NAME).is_file():
conf.set_section_key(lay.project, "tasks", STAGE_KEY, want)
said = [f"{CONFIG_NAME}: [tasks] {STAGE_KEY} = «{want}»"]
else:
said = write_config(lay.project, {STAGE_KEY: want})
print(f"стадия: {'объявлена' if declaring else STAGE_RU.get(lay.stage, '') + ''}"
f" {STAGE_RU[want]}")
for line in said:
print(f" {line}")
if merge:
print(f" секции слиты в «{sections[0]}»: перенесено строк {moved},"
f" поправлена «{PLACE_KEY}» у {retyped} файлов")
else:
print(f" секции беклога не тронуты: {', '.join(sections)}")
if span is None:
print(f" шапка {lay.name('backlog')} не размечена ({STAGE_OPEN}) —"
f" абзац про стадию перепиши сам: он называет прежнюю")
for name in skipped:
print(f" НЕ ТРОНУТ {name}: мета не пересобирается (нет поля"
f" **{PLACE_KEY}:**) — «{sections[0]}» проставь сам")
if declaring:
print(" беклог не тронут: объявление называет то, что уже верно")
elif want == SUPPORT:
print(" порядок строк с этого момента значит важность, а не зависимость:"
" прежний план шёл по зависимости, и как очередь он не расставлен."
" Первый заход — груминг (скилл av-dev:task-groom)")
@@ -2931,10 +3124,10 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
# Версия та же, что у `init`: каталог выводится из чужой раскладки сегодня и
# сегодняшним форматом, сколько бы лет ни было тому, из чего он выведен.
# Имён частей здесь нет — адаптация раскладывает всё по умолчаниям.
for path, text in init_files(lay, sections, stage_name, {}).items():
skeleton = init_files(lay, sections, stage_name)
for path, text in skeleton.items():
wr.file(path, text)
backlog_lines = init_files(lay, sections, stage_name,
{})[lay.index("backlog")].splitlines()
backlog_lines = skeleton[lay.index("backlog")].splitlines()
renames: dict[str, str] = {}
for it in pl.get("items", []):
@@ -2968,7 +3161,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
entry_line(lay, title, slug, it.get("why", "")))
if pl.get("rejected"):
head = init_files(lay, sections, stage_name, {})[lay.index("rejected")]
head = skeleton[lay.index("rejected")]
body = []
for line in pl["rejected"]:
line = re.sub(r"Был приоритет:", "Была секция:", line)
@@ -2979,8 +3172,14 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
# Через `index`, а не `file`: отбивка секций живёт на записи индекса, и
# каталог, собранный в обход неё, встречал бы человека ошибкой `check`
# на первом же прогоне.
wr.index(lay, "backlog", backlog_lines)
# на первом же прогоне. По той же причине здесь же место сырья: карта могла
# положить разведку без «Вопроса» в середину списка, и `check --fix` потом
# переставил бы её — то есть тронул бы порядок, который человек подтвердил.
raw_now = {f"{it.get('slug') or it['old_slug']}.md" for it in pl.get("items", [])
if (it.get("type") or "").lower() == RESEARCH
and not re.search(rf"^##\s+{re.escape(lay.cfg['question_heading'])}\s*$",
it.get("body", ""), flags=re.M | re.I)}
wr.index(lay, "backlog", raw_last(backlog_lines, raw_now))
if a.dry_run:
print(f"пробный прогон: записалось бы файлов {len(wr.writes)},"
@@ -3081,7 +3280,7 @@ def main() -> int:
p.add_argument("--rm-tag", dest="rm_tag")
p.add_argument("--dir")
p = sub.add_parser("move", help="переставить строку: место в очереди или другая секция")
p = sub.add_parser("move", help="переставить строку: место в списке или другая секция")
p.add_argument("slug")
p.add_argument("--section", help="другая категория беклога;"
" без него — текущая секция записи")