diff --git a/av-dev-pm/skills/session/SKILL.md b/av-dev-pm/skills/session/SKILL.md index a6d12ef..f1976f3 100644 --- a/av-dev-pm/skills/session/SKILL.md +++ b/av-dev-pm/skills/session/SKILL.md @@ -81,7 +81,7 @@ description: Ритуал между спринтами и ведение сам > не пример: выкладка, публикация и отправка данных третьей стороне не > откатываются тем более. -> **Пол для остатка:** остаток, из которого пропала польза, названная в хуке, — +> **Пол для остатка:** остаток, из которого пропала польза, названная в «зачем», — > это не сделанная задача, а вышедшая из спринта. ## Заморозка набора @@ -191,7 +191,7 @@ python3 $tk reopen <слаг> --dir D --reason … # приёмка не со сутью — **дефект критериев, о котором сообщают, а не молча дорабатывают**, и переоценка на сессии, где критерии видит человек. - **Сжать задачу до остатка** и отчитаться «сделана». Защита ослаблена там же. - Пол для остатка — польза, названная в хуке; проверяет его человек при приёмке, + Пол для остатка — польза, названная в «зачем»; проверяет его человек при приёмке, и `reopen` — его инструмент. - **Занизить урожай** — не заводить найденное по ходу. Защита: поимённая сверка с **сохранённым независимым отчётом**, а не с прозой исполнителя. Каждая diff --git a/av-dev-pm/skills/session/references/cadence.md b/av-dev-pm/skills/session/references/cadence.md index b75f57a..b74270a 100644 --- a/av-dev-pm/skills/session/references/cadence.md +++ b/av-dev-pm/skills/session/references/cadence.md @@ -22,7 +22,7 @@ первым вариантом. 3. **Вынеси пачкой** через `AskUserQuestion`, не больше трёх за раз. 4. **Ответ записывается в тело задачи, раздел «Вопросы» опустошается**, тег - снимается `edit --rm-tag question`, **хук переписывается**: «Решено: + снимается `edit --rm-tag question`, **«зачем» переписывается**: «Решено: …» на вопрос «почему это лежит в беклоге» уже не отвечает. Опустошение раздела — не уборка, а условие взятия: правило и причина в скилле `tasks`, [references/task-format.md](../../tasks/references/task-format.md). @@ -92,7 +92,7 @@ `close --implemented` только имея **конкретный коммит или строку документа**, закрывающие задачу, и ссылка идёт в доклад. Есть лишь косвенные признаки — не удаляй сам, вынеси в пачку вопросов. Сделана частично → задача - сжимается до остатка: тело правишь редактором, заголовок и хук — через + сжимается до остатка: тело правишь редактором, заголовок и «зачем» — через `edit`. 2. **Проверь, не отменена ли решением.** Документ, ADR или архивное изменение мог закрыть вопрос иначе — тогда `close --reason "<ссылка на @@ -104,7 +104,7 @@ месяцами. 4. **Пере-кластеризуй по общей причине.** Несколько задач, оказавшихся симптомами одного дефекта, сливаются в одну — это находка, которую интейк дать не мог. -5. **Гигиена полей** — протухший хук, вопрос в прозе, снятый ответ, свойство +5. **Гигиена полей** — протухшее «зачем», вопрос в прозе, снятый ответ, свойство репозитория в рамках, предписание процесса в теле. Список и правила — в скилле `tasks`. diff --git a/av-dev-pm/skills/tasks/SKILL.md b/av-dev-pm/skills/tasks/SKILL.md index ab460cf..c1e823a 100644 --- a/av-dev-pm/skills/tasks/SKILL.md +++ b/av-dev-pm/skills/tasks/SKILL.md @@ -25,9 +25,9 @@ description: Ведение задач и целей как каталога mar 2. **Файл — источник истины, индексы производны.** Разошлись — неправы индексы. Согласованность механизируема и проверяется командой, а не вниманием: всё, что ловит `tasks.py check`, не должно попадать ни в чек-лист, ни в промпт. - Поэтому **хук живёт в мета-строке файла**, а строка индекса его лишь - повторяет: пока хук лежал только в индексе, восстановление пропавшей строки - теряло его молча и навсегда. Единственное исключение намеренное: **в каком + Поэтому **«зачем» живёт в мете файла**, а строка индекса его лишь + повторяет: пока поле лежало только в индексе, восстановление пропавшей + строки теряло его молча и навсегда. Единственное исключение намеренное: **в каком индексе лежит задача, знают индексы** — «в спринте» это свойство спринта, а не файла, поля-состояния нет. 3. **Причина переживает запись.** Выкинутая без причины задача вернётся через @@ -93,7 +93,7 @@ docs/tasks/ - **Статус цели выводится.** Цель закрыта, когда у неё не осталось открытых задач; `[x]`/`[~]` руками не ведутся, а `close` цели с живыми задачами скрипт запретит. Единственная оговорка: цель без задач неотличима — «ещё не - разобрана» или «всё закрыто». Различает **тег `decomposed`** в мета-строке + разобрана» или «всё закрыто». Различает **тег `decomposed`** в мете цели: он ставится, когда цель разложена на задачи. Тег, а не строка в теле — потому что проверяется механически: `check` **напоминает** о нём у пустой цели (замечанием, не ошибкой — неразобранная цель это законное состояние), а `check @@ -111,10 +111,10 @@ docs/tasks/ ``` python3 $tk check --dir D # согласованность индексов + здоровье -python3 $tk check --dir D --fix # + починить дрейф (секция, заголовок, дубли, хук, дом) +python3 $tk check --dir D --fix # + починить дрейф (секция, заголовок, дубли, «зачем», форма меты) python3 $tk list --dir D [--stale] [--section S] [--type T] [--tag a,b] [--goal S] [--index …] [--questions] -python3 $tk add --dir D --slug S --title T [--type goal|idea|epic] [--section S] [--goal G] [--hook H] [--tag a,b] -python3 $tk edit S --dir D [--title T] [--hook H] [--type T] [--goal G] [--add-tag a,b] [--rm-tag c] +python3 $tk add --dir D --slug S --title T [--type goal|idea|epic] [--section S] [--goal G] [--why «зачем»] [--tag a,b] +python3 $tk edit S --dir D [--title T] [--why «зачем»] [--type T] [--goal G] [--add-tag a,b] [--rm-tag c] python3 $tk move S --dir D --section S [--reason R] [--after S | --first] python3 $tk close S --dir D --reason R # в REJECTED.md + удалить (ушла без реализации) python3 $tk close S --dir D --implemented # просто удалить (реализована и закоммичена) @@ -141,9 +141,9 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап токены команд); `task` префикса не несёт, остальные кодируются `[goal]`/ `[idea]`/`[epic]` в заголовке. Текст задачи при этом русский. -**Мутации правят файл и индексы заодно** — руками строку индекса или мета-строку -не пиши, зови `add`/`edit`/`move`/`close`/`sprint`. Смена заголовка, хука, типа, -цели и **тегов** — это `edit`: он держит H1, мета-строку и индекс в синхроне. +**Мутации правят файл и индексы заодно** — руками строку индекса или мету +не пиши, зови `add`/`edit`/`move`/`close`/`sprint`. Смена заголовка, «зачем», типа, +цели и **тегов** — это `edit`: он держит H1, мету и индекс в синхроне. Снятие тега — `--rm-tag` (после ответа на вопрос снимается `question`), смена цели — `--goal`, он заменяет прежний `goal:*`. @@ -156,14 +156,14 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап объяснит. Задача в наборе спринта тип не меняет вовсе: сперва `sprint drop`. Тело задачи скрипт не трогает: -`add` кладёт заголовок, мета-строку и шаблон с подсказками, тело дописываешь +`add` кладёт заголовок, мета-блок и шаблон с подсказками, тело дописываешь редактором (пока плейсхолдер на месте, `check` напоминает). `check` — единственный судья согласованности; что именно он ловит, скажет его вывод, здесь не пересказываем. Гоняй его **в начале сессии** и **после каждой правки**, даже если правил мутациями: дрейф мог накопиться раньше. Накопившееся чини `check --fix` — он детерминированно правит то, где истина однозначна -(секция, заголовок, дубли, хук из индекса в файл, строка в чужом индексе, +(секция, заголовок, дубли, «зачем» из индекса в файл, старая форма меты, пометка `decomposed` у цели с задачами), а неоднозначное (задача сразу в двух индексах, нечего восстанавливать) печатает отдельной пометкой `НЕОДНОЗНАЧНО` — это тебе, и это идёт строкой доклада. **Ссылка на исчезнувший файл в пометку не @@ -171,7 +171,7 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап `check` — то есть видна, но в докладе её надо назвать отдельно. `--fix` правит **и файлы** — ровно в двух местах, где источник ровно один и -выбирать не из чего: хук, оставшийся только в индексе, переезжает в мета-строку, +выбирать не из чего: «зачем», оставшееся только в индексе, переезжает в мету, и цель, у которой есть задачи, получает тег `decomposed`. Оба случая печатаются поимённо. @@ -182,7 +182,7 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап отличает, поэтому эвристика даёт только замечание, и в докладе это называется как есть: «проверено число пунктов, годность оракулов — глазами». -Формат файла, мета-строки, слага, индексов и `REJECTED.md` — +Формат файла, меты, слага, индексов и `REJECTED.md` — [references/task-format.md](references/task-format.md). Там же тест «готова к взятию» и требования к критериям приёмки. @@ -193,7 +193,7 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап 1. **Фильтр.** Делаем прямо сейчас — не заводим. Не пожалеем о потере — не заводим. Родилось три кандидата — покажи их и спроси, какие заводить: молча заведённая пачка и есть тот самый отказ из правила 1. -2. **Дедуп.** `list` плюс поиск по слагам, хукам и телам (`grep -ril`), +2. **Дедуп.** `list` плюс поиск по слагам, полю «зачем» и телам (`grep -ril`), **включая `REJECTED.md`**. Нашлось среди живых — **дописываем в существующий файл**, а не заводим соседний. Нашлось в `REJECTED.md` — покажи пользователю ту строку и что изменилось с момента отказа (`add` предупредит и сам, но @@ -209,7 +209,7 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап заводить её не надо. У идеи цели может не быть — она проставляется, когда идея становится задачей. 5. `add …`, затем допиши тело редактором: одна фраза, критерии приёмки с - оракулами, рамки. Хук отвечает «почему это лежит в беклоге» — состояние, + оракулами, рамки. «Зачем» отвечает «зачем нужна эта задача» — состояние, остаток, боль, — а не пересказывает первый абзац, и пишется **для человека**: не «канонизация внутри транзакции», а «тело 40 МиБ держит блокировку 5 секунд, соседние доставки уходят в отказ». @@ -245,10 +245,10 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап Правится по ходу любой операции, которая задачи касается (но не «заодно» по всему беклогу): -- **протухший хук** — задача изменилась, а хук отвечает на старый вопрос; +- **протухшее «зачем»** — задача изменилась, а поле отвечает на старый вопрос; особенно после ответа на вопрос задачи: «Решено: …» на «почему это лежит в - беклоге» уже не отвечает. Переписывается `edit --hook …` — он правит - мета-строку файла и строку индекса заодно; + беклоге» уже не отвечает. Переписывается `edit --why …` — он правит + мету файла и строку индекса заодно; - **вопрос, застрявший в прозе** — вынимается в раздел «Вопросы» плюс тег `question` (`edit --add-tag question`), иначе он не виден ни `list --questions`, ни правилу «задача с открытым вопросом в набор не берётся»; @@ -329,7 +329,7 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап - **Ничего не удаляем молча.** Файл исчезает только через `close` — `--reason` (ушла без реализации) или `--implemented` (реализована). Прямого `rm` нет. - **Слаги английские**, kebab-case, не транслит: `tie-break-equal-completeness`, - а не `taj-brejk-pri-ravnoj-polnote`. Заголовки, тела и хуки — русские. + а не `taj-brejk-pri-ravnoj-polnote`. Заголовки, тела и «зачем» — русские. ## Чего этот скилл не делает diff --git a/av-dev-pm/skills/tasks/references/adopt.md b/av-dev-pm/skills/tasks/references/adopt.md index 6f6f076..ba4617f 100644 --- a/av-dev-pm/skills/tasks/references/adopt.md +++ b/av-dev-pm/skills/tasks/references/adopt.md @@ -21,7 +21,7 @@ пишется хоть один файл. Это то же правило, что у интейка находок ревью: массовое заведение записей без подтверждения — самый дорогой отказ, потому что разгребает его потом переоценка. -2. **Ничего не терять.** Исходный текст переезжает в тело, хук и причина +2. **Ничего не терять.** Исходный текст переезжает в тело, «зачем» и причина сохраняются, кладбище переносится строка в строку. Переименование слага — не правка, а **перенос ссылок**: он делается одним проходом вместе с переименованием, иначе останутся битые ссылки, которых никто не проверяет. @@ -44,7 +44,7 @@ python3 $tk adopt apply --plan tasks-adopt-plan.json \ ``` `scan` ничего не пишет, кроме карты: он распознаёт раскладку, собирает записи, -хуки, причины, кладбище, помечает похожее на транслит и на открытый вопрос в +поля «зачем», причины, кладбище, помечает похожее на транслит и на открытый вопрос в прозе, и **называет поимённо** то, что не разложилось. `apply` пишет каталог целиком одним проходом и чинит перекрёстные ссылки. diff --git a/av-dev-pm/skills/tasks/references/from-review.md b/av-dev-pm/skills/tasks/references/from-review.md index 2435c52..eb42ed5 100644 --- a/av-dev-pm/skills/tasks/references/from-review.md +++ b/av-dev-pm/skills/tasks/references/from-review.md @@ -69,7 +69,7 @@ - **тяжёлая находка со свидетельством** → задача под ту цель, которой она угрожает, и **кандидат в ближайший набор**: серьёзность здесь превращается в довод при выборе цели следующего спринта, а не в уровень в файле. Довод - записывается причиной в мета-строке (`--reason`), иначе к моменту набора его + записывается причиной в мете (`--reason`), иначе к моменту набора его никто не вспомнит; - **находка, ломающая уже идущий спринт**, — не интейк вовсе: см. правило вторжения в скилле `session`. В беклог она падает, только если врываться не diff --git a/av-dev-pm/skills/tasks/references/task-format.md b/av-dev-pm/skills/tasks/references/task-format.md index 43bea6b..fee4841 100644 --- a/av-dev-pm/skills/tasks/references/task-format.md +++ b/av-dev-pm/skills/tasks/references/task-format.md @@ -1,6 +1,6 @@ # Формат задач, целей и индексов -Заголовок, мета-строку и строку индекса ставит `tasks.py add` — руками их не +Заголовок, мета-блок и строку индекса ставит `tasks.py add` — руками их не пишут. Этот файл описывает, что именно скрипт создаёт и что проверяет `check`; тело задачи (одну фразу, критерии, рамки, контекст) дописывает агент. @@ -11,7 +11,9 @@ ```markdown # Тай-брейк при равной полноте -**Секция:** ядро — вышла из спринта: остаток писал нерешённое в журнал · **Хук:** порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт · **Теги:** goal:merge-robustness, sprint:2026-08-03 +- **Секция:** ядро — вышла из спринта: остаток писал нерешённое в журнал +- **Зачем:** порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт +- **Теги:** goal:merge-robustness, sprint:2026-08-03 При столкновении точек выигрывает более полная, но при равной полноте побеждает последняя доставка — а она систематически беднее первой. @@ -33,19 +35,27 @@ префиксом `[goal]` / `[idea]` / `[epic]`; обычная задача — без префикса. Отдельного поля типа **нет**: два места для одного факта разъезжаются, а префикс виден прямо в индексе, где и принимается решение «брать или не брать». -- **Мета-строка** — первая непустая строка после заголовка. Обязательна секция, - причина после тире желательна (именно она объясняет, почему задача здесь - оказалась — в том числе «вышла из спринта: …»), хук и теги опциональны. Поля - разделяются ` · `, порядок свободный. `·` — служебный разделитель: в тексте - причины и хука его быть не должно. -- **Хук живёт здесь, а не только в индексе.** Строка индекса его повторяет и - производна от него: `check` сверяет, `check --fix` восстанавливает пропавшую - строку **вместе с хуком**. Пока хук лежал только в индексе, штатная починка - дрейфа теряла его молча и навсегда — а хук это единственное, по чему задачу - выбирают, не открывая. +- **Мета-блок** — список сразу после заголовка, **поле на строку**. Обязательна + секция, причина после тире желательна (именно она объясняет, почему задача + здесь оказалась — в том числе «вышла из спринта: …»), «зачем» и теги + опциональны. Порядок свободный, поле в одну строку. Нераспознанные поля + сохраняются: скрипт правит свои и не трогает чужие. +- **«Зачем» отвечает на «зачем нужна эта задача»** — состояние, остаток, боль. + Не пересказ задачи: пересказ уже есть по ссылке. Живёт здесь, а не только в + индексе: строка индекса его повторяет и производна от него, `check` сверяет, + `check --fix` восстанавливает пропавшую строку **вместе с ним**. Пока поле + лежало только в индексе, штатная починка дрейфа теряла его молча и + навсегда — а это единственное, по чему задачу выбирают, не открывая. + - **Тело** — одна фраза «что станет наблюдаемо иначе», критерии приёмки, рамки, контекст, ссылки. Пишется на языке документации проекта. +Мета **одной строкой через `·`** — прежняя форма. Она читается по-прежнему, +`check` называет её дрейфом, `check --fix` переписывает списком; поле `Хук` +при этом становится `Зачем`. Причина отказа от строки простая: с тремя полями +и длинным «зачем» строка уезжала за экран, а `·` приходилось запрещать в тексте +причины и самого «зачем». + Тело — не план реализации и не спецификация: принятое и реализованное переезжает в документацию проекта, а файл задачи удаляется. @@ -95,7 +105,7 @@ **Ответ на вопрос — три правки, и первая обязательна.** Раздел «Вопросы» опустошается: ответ переезжает в тело решением, а не остаётся вопросом рядом с ответом. Затем снимается тег (`edit --rm-tag question`) и переписывается -хук: «Решено: …» на вопрос «почему это лежит в беклоге» уже не отвечает. +«зачем»: «Решено: …» на вопрос «зачем нужна эта задача» уже не отвечает. **Порядок именно такой, потому что судит раздел, а не тег.** `sprint take` смотрит в непустой раздел и откажет взять задачу даже со снятым тегом, а `check` @@ -107,7 +117,8 @@ ```markdown # [goal] Прочность слияния -**Секция:** темы · **Теги:** decomposed +- **Секция:** темы +- **Теги:** decomposed Ради чего: точки из разных доставок сходятся в один часовой объект, и сегодня исход столкновения зависит от порядка доставки, а не от содержания. @@ -148,10 +159,10 @@ Строка везде одной формы: ```markdown -- [Заголовок дословно](items/slug.md) — хук +- [Заголовок дословно](items/slug.md) — зачем ``` -Хук отвечает на «почему это лежит в беклоге» одним предложением: состояние, +«Зачем» отвечает на «зачем нужна эта задача» одним предложением: состояние, остаток, боль. Пересказ первого абзаца бесполезен — он уже есть по ссылке. | Файл | Что отвечает | Секции | @@ -208,7 +219,7 @@ ## Теги Единственный механизм разметки, потому что `list --tag` уже умеет отбирать по -ним порцию разбора. Отдельных полей мета-строки под это не заводим. +ним порцию разбора. Отдельных полей меты под это не заводим. - `goal:<слаг>` — цель, которой служит задача. Обязателен: задача без цели не попадёт ни в один спринт. diff --git a/av-dev-pm/skills/tasks/scripts/tasks.py b/av-dev-pm/skills/tasks/scripts/tasks.py index 1d0d503..429d5e9 100755 --- a/av-dev-pm/skills/tasks/scripts/tasks.py +++ b/av-dev-pm/skills/tasks/scripts/tasks.py @@ -18,7 +18,7 @@ av-dev, и подгоняется под него проект. Имена вн REJECTED.md ушедшее БЕЗ реализации, с причиной и датой Источник истины — файл задачи в items/. Индексы производны: расходятся — -неправ индекс. **Хук живёт в мета-строке файла**, а не только в строке индекса: +неправ индекс. **«Зачем» живёт в мете файла**, а не только в строке индекса: иначе восстановление пропавшей строки (`check --fix`) теряло бы его навсегда. Исключение из производности одно и оно намеренное: **в каком индексе лежит задача — знают индексы**, потому что «в спринте» это свойство спринта, а не @@ -38,8 +38,8 @@ av-dev, и подгоняется под него проект. Имена вн tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag a,b] [--goal S] [--index backlog|sprint|plan|all] [--questions] tasks.py add --slug S --title T [--type goal|idea|epic] [--section S] - [--goal G] [--hook H] [--reason R] [--tag a,b] [--dir DIR] - tasks.py edit S [--title T] [--hook H] [--type T] [--goal G] + [--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 close S (--reason R | --implemented) [--dir DIR] @@ -64,11 +64,11 @@ av-dev, и подгоняется под него проект. Имена вн 4 внутренний сбой (непойманное исключение) — это дефект скрипта Тело задачи (контекст, критерии, вопросы, ссылки) остаётся агенту — add кладёт -заголовок, мета-строку и шаблон-плейсхолдер; агент дописывает редактором. +заголовок, мета-блок и шаблон-плейсхолдер; агент дописывает редактором. Границы безопасности: слаг — только латиница kebab-case (traversal невозможен), ---dir обязан быть внутри рабочего каталога, в заголовок/хук/причину не пролезет -перевод строки, `·` в хуке и причине запрещён (это разделитель мета-полей). +--dir обязан быть внутри рабочего каталога, в заголовок, «зачем» и причину не +пролезет перевод строки: поле меты — ровно одна строка. Все проверки идут до первой записи; запись — одним проходом (см. Plan). @@ -112,7 +112,50 @@ PATH_KEYS = ("items", "backlog", "plan", "sprint", "rejected") DEFAULT_SECTIONS = "ядро,инфра" DEFAULT_PLAN_SECTIONS = "порядок,темы" +# Мета — список под заголовком, поле на строку. Старая форма (все поля одной +# строкой через `·`) читается по-прежнему: у проектов на диске лежат файлы в +# ней, и `check --fix` переписывает их в новую. Ради этого разделитель `·` и +# оставался зарезервированным — в новой форме он ничего не разделяет. +META_ITEM = re.compile(r"^-\s+\*\*(.+?):\*\*\s*(.*)$") META_FIELD = re.compile(r"^\*\*(.+?):\*\*\s*(.*)$") + +# «Хук» — прежнее имя поля «Зачем». Читается, чтобы файлы проектов переезжали +# сами; пишется всегда новое. +WHY_KEYS = ("зачем", "why", "хук", "hook") + + +def meta_span(lines: list[str]) -> tuple[int, int] | None: + """Границы мета-блока — `[начало, конец)`. None, если меты нет. + + Мета начинается первой непустой строкой после заголовка. Список тянется, + пока строки ему принадлежат; старая форма занимает ровно одну строку. + """ + i = next((i for i in range(1, len(lines)) if lines[i].strip()), None) + if i is None: + return None + if META_ITEM.match(lines[i].strip()): + j = i + while j < len(lines) and META_ITEM.match(lines[j].strip()): + j += 1 + return i, j + return (i, i + 1) if META_FIELD.match(lines[i].strip()) else None + + +def meta_legacy(lines: list[str], span: tuple[int, int]) -> bool: + """Мета в старой форме — одной строкой. Это дрейф, чинит `check --fix`.""" + start, end = span + return end - start == 1 and not META_ITEM.match(lines[start].strip()) + + +def meta_fields(lines: list[str], span: tuple[int, int]) -> list[tuple[str, str]]: + """Поля мета-блока парами «ключ, значение», в порядке файла.""" + start, end = span + chunks = lines[start].split("·") if meta_legacy(lines, span) else lines[start:end] + out = [] + for chunk in chunks: + if (f := META_ITEM.match(chunk.strip()) or META_FIELD.match(chunk.strip())): + out.append((f.group(1).strip(), f.group(2).strip())) + return out INDEX_ENTRY = re.compile(r"^- \[(.+?)\]\((.+?\.md)\)\s*(?:—\s*(.*))?$") SECTION = re.compile(r"^##\s+(.+?)\s*$") TYPE_PREFIX = re.compile(r"^\[(.+?)\]\s*(.*)$") @@ -163,22 +206,16 @@ def bad_slug(slug: str) -> str | None: def bad_meta_text(value: str | None, field: str) -> str | None: - """Причина и хук едут в мета-строку, где `·` — разделитель полей.""" - if value is None: - return None - if (e := bad_line(value, field)): - return e - if "·" in value: - return f"{field}: символ · зарезервирован под разделитель мета-полей" - return None + """Поле меты — ровно одна строка: мета читается построчно.""" + return None if value is None else bad_line(value, field) def bad_reason(reason: str | None) -> str | None: return bad_meta_text(reason, "причина") -def bad_hook(hook: str | None) -> str | None: - return bad_meta_text(hook, "хук") +def bad_why(why: str | None) -> str | None: + return bad_meta_text(why, "зачем") def bad_tags(raw: str | None) -> str | None: @@ -425,9 +462,9 @@ def parse_entries(lines: list[str]) -> tuple[dict[str, dict], list[str]]: continue m = INDEX_ENTRY.match(line) if m: - title, target, hook = m.group(1), m.group(2), (m.group(3) or "").strip() + title, target, why = m.group(1), m.group(2), (m.group(3) or "").strip() entries[Path(target).name] = { - "title": title, "section": section, "hook": hook, + "title": title, "section": section, "why": why, "line": num, "target": target} return entries, sections @@ -447,7 +484,7 @@ def index_lint(lines: list[str], label: str) -> list[str]: m = INDEX_ENTRY.match(line) if not m: errors.append(f"{label}:{num}: строка-пункт не по формату" - f" «- [Заголовок](items/slug.md) — хук»") + f" «- [Заголовок](items/slug.md) — зачем»") continue target = Path(m.group(2)).name if section is None: @@ -489,28 +526,24 @@ def parse_task(path: Path) -> dict: kind, bare = PLAIN_TYPE, title if (m := TYPE_PREFIX.match(title)): kind, bare = m.group(1).strip().lower(), m.group(2).strip() - # Мета-строка — первая непустая строка после заголовка (task-format.md). - # Поля разделены `·`, порядок свободный: секция распознаётся, где бы она ни - # стояла. Ни причина, ни хук не должны содержать `·` — это разделитель. - meta = next((ln.strip() for ln in lines[1:] if ln.strip()), "") - section, reason, hook, tags = "", "", "", [] - if META_FIELD.match(meta): - for chunk in meta.split("·"): - f = META_FIELD.match(chunk.strip()) - if not f: - continue - key, value = f.group(1).strip().lower(), f.group(2).strip() + # Мета — блок под заголовком (task-format.md). Порядок полей свободный: + # секция распознаётся, где бы она ни стояла. + section, reason, why, tags, legacy = "", "", "", [], False + if (span := meta_span(lines)): + legacy = meta_legacy(lines, span) + for key, value in meta_fields(lines, span): + key = key.lower() if key in ("секция", "section"): section, _, reason = (p.strip() for p in value.partition("—")) section = section.rstrip(".,").lower() - elif key in ("хук", "hook"): - hook = value + elif key in WHY_KEYS: + why = value elif key in ("теги", "tags"): tags = [t.strip().lower() for t in value.split(",") if t.strip()] goal = next((t[len(GOAL_TAG):] for t in tags if t.startswith(GOAL_TAG)), "") return {"title": title, "bare": bare, "type": kind, "section": section, - "reason": reason, "hook": hook, "tags": tags, "goal": goal, "path": path, - "text": text, "body": body_sections(text)} + "reason": reason, "why": why, "tags": tags, "goal": goal, "path": path, + "legacy_meta": legacy, "text": text, "body": body_sections(text)} def tasks_of(lay: Layout) -> dict[str, dict]: @@ -702,8 +735,11 @@ def check(lay: Layout, fix: bool = False) -> int: # 2. Секция файла — истина, секция индекса производна. В спринте секция # файла хранит, куда задачу вернуть, и с «Набором» не сверяется. + if task["legacy_meta"]: + errors.append(f"{name}: мета одной строкой — старая форма;" + f" `check --fix` перепишет её списком") if not task["section"]: - errors.append(f"{name}: нет поля **Секция:** в мета-строке") + errors.append(f"{name}: нет поля **Секция:** в мета-блоке") elif task["section"] not in known[home]: errors.append(f"{name}: секция «{task['section']}» не совпадает ни с одной" f" секцией {label[home]} ({', '.join(sections[home])})") @@ -717,18 +753,18 @@ def check(lay: Layout, fix: bool = False) -> int: f" файл: {task['title']}\n" f" индекс: {entry['title']}") - # 2а. Хук: истина в файле, строка индекса производна. Пока хук жил - # только в индексе, восстановление пропавшей строки его теряло. - if entry and task["hook"] and entry["hook"] != task["hook"]: - errors.append(f"{name}: хук разошёлся (истина в файле)\n" - f" файл: {task['hook']}\n" - f" индекс: {entry['hook']}") - elif place is not None and entry and not task["hook"] and entry["hook"]: - errors.append(f"{name}: хук есть в {label[place]}, а в файле нет —" - f" `check --fix` перенесёт его в мета-строку") - elif not task["hook"]: - notes.append(f"{name}: без хука — по нему выбирают задачу" - f" (`tasks.py edit {name[:-3]} --hook …`)") + # 2а. «Зачем»: истина в файле, строка индекса производна. Пока поле + # жило только в индексе, восстановление строки его теряло. + if entry and task["why"] and entry["why"] != task["why"]: + errors.append(f"{name}: «зачем» разошлось (истина в файле)\n" + f" файл: {task['why']}\n" + f" индекс: {entry['why']}") + elif place is not None and entry and not task["why"] and entry["why"]: + errors.append(f"{name}: «зачем» есть в {label[place]}, а в файле нет —" + f" `check --fix` перенесёт его в мета-блок") + elif not task["why"]: + notes.append(f"{name}: без «зачем» — по нему выбирают задачу" + f" (`tasks.py edit {name[:-3]} --why …`)") # 3. Цель. Связь однонаправленна: задача несёт goal:<слаг>, перечень # задач цели выводится `list --goal`, а не хранится. @@ -811,7 +847,7 @@ def check(lay: Layout, fix: bool = False) -> int: errors.append(f"{lay.name('rejected')}:{num}:" f" строка не по формату «- ГГГГ-ММ-ДД `slug` — …»") - # Причина в мета-строке желательна, но не обязательна. Ругаемся только на + # Причина в мете желательна, но не обязательна. Ругаемся только на # частичное покрытие: у части задач причина есть, у части нет — это дрейф. # Ноль из N — осознанный отказ проекта от причин, не расхождение; горящее # на каждом check замечание агент просто научится игнорировать. @@ -947,15 +983,13 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int: # --- Мутации: правят файл и индексы заодно, рассогласовать их вручную нельзя --- -def build_meta(section: str, reason: str, hook: str, tags: list[str]) -> str: - s = f"**Секция:** {section}" - if reason: - s += f" — {reason}" - if hook: - s += f" · **Хук:** {hook}" +def build_meta(section: str, reason: str, why: str, tags: list[str]) -> str: + out = [f"- **Секция:** {section}" + (f" — {reason}" if reason else "")] + if why: + out.append(f"- **Зачем:** {why}") if tags: - s += " · **Теги:** " + ", ".join(tags) - return s + out.append("- **Теги:** " + ", ".join(tags)) + return "\n".join(out) def section_headers(lines: list[str]) -> list[tuple[int, str]]: @@ -980,7 +1014,7 @@ def find_entry_index(lines: list[str], slug: str) -> int | None: def locate_all(lay: Layout, slug: str) -> dict[str, tuple[list[str], int]]: """Все индексы, где есть строка задачи. Правится **каждый**: иначе задача, случайно попавшая в два индекса, чинится наполовину и вторая строка - остаётся со старым заголовком и старым хуком.""" + остаётся со старым заголовком и старым «зачем».""" found: dict[str, tuple[list[str], int]] = {} for kind in lay.indexes: lines = read_lines(lay.index(kind)) @@ -1015,60 +1049,60 @@ def insert_entry(lines: list[str], section: str, entry: str, lines.insert(ins, entry) -def meta_updated(path: Path, section: str | None = None, reason: str | None = None, - hook: str | None = None, tags: list[str] | None = None) -> str | None: - """Новый текст файла с хирургически поправленной мета-строкой. +def meta_rebuilt(lines: list[str], section: str | None = None, reason: str | None = None, + why: str | None = None, tags: list[str] | None = None) -> list[str] | None: + """Строки файла с пересобранным мета-блоком. None — меты нет. - Нераспознанные поля и регистр сохраняются. None — мета-строки нет (тогда - правку делать нельзя). Ничего не пишет: запись — дело Plan. + Пересобирается **весь блок**, а не правится по месту: заодно старая форма + (всё одной строкой через `·`) переезжает в новую. Нераспознанные поля + переносятся как есть, с их написанием ключа, и встают после известных — + терять чужое поле нельзя, но и порядок ему диктовать незачем. + + `None` в аргументе — «не трогать», пустая строка — «убрать поле». Ничего не + пишет: запись — дело Plan. """ - flines = path.read_text(encoding="utf-8").splitlines() - mi = next((i for i in range(1, len(flines)) if flines[i].strip()), None) - if mi is None or not META_FIELD.match(flines[mi].strip()): + span = meta_span(lines) + if span is None: return None - chunks: list[str | None] = [*flines[mi].split("·")] - seen_section = seen_tags = seen_hook = False - tags_at = None - for idx, chunk in enumerate(chunks): - if chunk is None: # вычеркнутое поле — своим же проходом ниже - continue - f = META_FIELD.match(chunk.strip()) - if not f: - continue - key = f.group(1).strip().lower() - lead = chunk[:len(chunk) - len(chunk.lstrip())] - trail = chunk[len(chunk.rstrip()):] - if key in ("секция", "section") and (section is not None or reason is not None): - old_section, _, old_reason = (p.strip() for p in f.group(2).partition("—")) - sect = section if section is not None else old_section - rsn = reason if reason is not None else old_reason - chunks[idx] = lead + f"**Секция:** {sect}" + (f" — {rsn}" if rsn else "") + trail + old = meta_fields(lines, span) + cur_section, cur_reason, cur_why, cur_tags, extra = "", "", "", "", [] + seen_section = False + for key, value in old: + low = key.lower() + if low in ("секция", "section"): + cur_section, _, cur_reason = (p.strip() for p in value.partition("—")) seen_section = True - elif key in ("секция", "section"): - seen_section = True - elif key in ("хук", "hook"): - seen_hook = True - if hook is not None: - chunks[idx] = lead + f"**Хук:** {hook}" + trail if hook else None - elif key in ("теги", "tags"): - seen_tags = True - tags_at = idx - if tags is not None: - chunks[idx] = lead + "**Теги:** " + ", ".join(tags) + trail if tags else None - if hook and not seen_hook: - at = tags_at if tags_at is not None else len(chunks) - chunks.insert(at, " **Хук:** " + hook + " ") - if tags and not seen_tags: - chunks.append(" **Теги:** " + ", ".join(tags)) - if section is not None and not seen_section: - return None - kept = [c for c in chunks if c is not None] - return "\n".join((*flines[:mi], "·".join(kept).rstrip(), *flines[mi + 1:])) + "\n" + elif low in WHY_KEYS: + cur_why = value + elif low in ("теги", "tags"): + cur_tags = value + else: + extra.append(f"- **{key}:** {value}") + if not seen_section: + return None # мета без секции сломана; `check` скажет это словами + out = [f"- **Секция:** {section if section is not None else cur_section}"] + rsn = reason if reason is not None else cur_reason + if rsn: + out[0] += f" — {rsn}" + new_why = why if why is not None else cur_why + if new_why: + out.append(f"- **Зачем:** {new_why}") + new_tags = ", ".join(tags) if tags is not None else cur_tags + if new_tags: + out.append(f"- **Теги:** {new_tags}") + return [*lines[:span[0]], *out, *extra, *lines[span[1]:]] -def entry_line(lay: Layout, title: str, slug: str, hook: str) -> str: +def meta_updated(path: Path, section: str | None = None, reason: str | None = None, + why: str | None = None, tags: list[str] | None = None) -> str | None: + lines = path.read_text(encoding="utf-8").splitlines() + out = meta_rebuilt(lines, section=section, reason=reason, why=why, tags=tags) + return None if out is None else "\n".join(out) + "\n" + + +def entry_line(lay: Layout, title: str, slug: str, why: str) -> str: link = f"{lay.cfg['items']}/{slug}.md" - return f"- [{title}]({link})" + (f" — {hook}" if hook else "") + return f"- [{title}]({link})" + (f" — {why}" if why else "") def body_template(kind: str, lay: Layout) -> str: @@ -1090,7 +1124,7 @@ def body_template(kind: str, lay: Layout) -> str: def cmd_add(lay: Layout, a: argparse.Namespace) -> int: - for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_hook(a.hook), + for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_why(a.why), bad_tags(a.tag), bad_reason(a.reason), bad_slug(a.goal) if a.goal else None): if err: raise Usage(err) @@ -1130,8 +1164,8 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int: print(f" тег «{QUESTION_TAG}»: не забудь раздел «{lay.cfg['questions_heading']}» в теле") title_full = f"[{kind}] {a.title}" if kind else a.title - meta = build_meta(section, a.reason or "", a.hook or "", tags) - insert_entry(lines, section, entry_line(lay, title_full, a.slug, a.hook or "")) + meta = build_meta(section, a.reason or "", a.why or "", tags) + insert_entry(lines, section, entry_line(lay, title_full, a.slug, a.why or "")) plan = Plan() plan.file(path, f"# {title_full}\n\n{meta}\n\n{body_template(kind, lay)}") @@ -1142,8 +1176,8 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int: f" строка в {lay.name(target)} (секция «{section}»); допиши тело редактором") if sslug and f"{SPRINT_TAG}{sslug}" in tags: print(f" помечено тегом {SPRINT_TAG}{sslug} — урожай идущего спринта") - if not a.hook: - print(f" без хука — задай: tasks.py edit {a.slug} --hook …") + if not a.why: + print(f" без «зачем» — задай: tasks.py edit {a.slug} --why …") warn_rejected(lay, a.slug, a.title) return EXIT_OK @@ -1165,12 +1199,12 @@ def warn_rejected(lay: Layout, slug: str, title: str) -> None: def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: - for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_hook(a.hook), + for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_why(a.why), bad_tags(a.add_tag), bad_tags(a.rm_tag), bad_slug(a.goal) if a.goal else None): if err: raise Usage(err) - if all(v is None for v in (a.title, a.hook, a.type, a.goal, a.add_tag, a.rm_tag, a.section)): - raise Usage("нечего менять: дай --title, --hook, --type, --goal, --add-tag или --rm-tag") + if all(v is None for v in (a.title, a.why, a.type, a.goal, a.add_tag, a.rm_tag, a.section)): + raise Usage("нечего менять: дай --title, --why, --type, --goal, --add-tag или --rm-tag") path = lay.items / f"{a.slug}.md" if not path.exists(): raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/") @@ -1213,7 +1247,7 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: if not (lay.items / f"{a.goal}.md").exists(): print(f" внимание: цели {a.goal}.md нет — заведи её или поправь тег") - hook = task["hook"] if a.hook is None else a.hook + why = task["why"] if a.why is None else a.why section = task["section"] if a.section is not None: if old_home == new_home: @@ -1235,10 +1269,10 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: section = section_name.lower() new_text = meta_updated(path, section=section if section else None, - hook=hook if a.hook is not None else None, + why=why if a.why is not None else None, tags=tags if tags != task["tags"] else None) if new_text is None: - raise Usage(f"{a.slug}.md без мета-строки **Секция:** — прогони check и почини") + raise Usage(f"{a.slug}.md без поля **Секция:** в мете — прогони check и почини") tlines = new_text.splitlines() tlines[0] = f"# {h1}" new_text = "\n".join(tlines) + "\n" @@ -1250,15 +1284,15 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: old_lines.pop(ei) # строка в новом индексе собирается заново plan.index(lay, old_home, old_lines) target_lines = read_lines(lay.index(new_home)) - insert_entry(target_lines, section, entry_line(lay, h1, a.slug, hook)) + insert_entry(target_lines, section, entry_line(lay, h1, a.slug, why)) plan.index(lay, new_home, target_lines) else: for kind_index, (lines, ei) in places.items(): - lines[ei] = entry_line(lay, h1, a.slug, hook) + lines[ei] = entry_line(lay, h1, a.slug, why) plan.index(lay, kind_index, lines) plan.commit() - changed = [n for n, v in (("заголовок", a.title), ("хук", a.hook), ("тип", a.type), + changed = [n for n, v in (("заголовок", a.title), ("зачем", a.why), ("тип", a.type), ("цель", a.goal), ("теги", a.add_tag or a.rm_tag)) if v is not None] print(f"{a.slug}: обновлено ({', '.join(changed)})") @@ -1299,7 +1333,7 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int: raise Usage(f"нет секции «{a.section}» в {lay.name(kind_index)} (есть: {avail})") new_text = meta_updated(path, section=section, reason=a.reason) if new_text is None: - raise Usage(f"{a.slug}.md без мета-строки **Секция:** — прогони check и почини") + raise Usage(f"{a.slug}.md без поля **Секция:** в мете — прогони check и почини") entry = lines.pop(ei) try: insert_entry(lines, section, entry, a.after, a.first) @@ -1409,7 +1443,7 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: if a.reason: upd = meta_updated_text(text, reason=a.reason) if upd is None: - print(" внимание: мета-строки нет, причина возврата не записана в файл") + print(" внимание: меты нет, причина возврата не записана в файл") else: text = upd tmp = parse_task_text(text, path) @@ -1427,7 +1461,7 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: if hi is None: 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["hook"])) + insert_entry(lines, sec, entry_line(lay, title, a.slug, tmp["why"])) plan.index(lay, target, lines) rej = lay.index("rejected") @@ -1464,18 +1498,9 @@ def parse_task_text(text: str, path: Path) -> dict: def meta_updated_text(text: str, reason: str) -> str | None: - lines = text.splitlines() - mi = next((i for i in range(1, len(lines)) if lines[i].strip()), None) - if mi is None or not META_FIELD.match(lines[mi].strip()): - return None - chunks = lines[mi].split("·") - for idx, chunk in enumerate(chunks): - f = META_FIELD.match(chunk.strip()) - if f and f.group(1).strip().lower() in ("секция", "section"): - section = f.group(2).partition("—")[0].strip() - chunks[idx] = f"**Секция:** {section} — {reason}" - return "\n".join((*lines[:mi], "·".join(chunks), *lines[mi + 1:])) + "\n" - return None + """То же для текста, которого ещё нет на диске (возврат задачи из git).""" + out = meta_rebuilt(text.splitlines(), reason=reason) + return None if out is None else "\n".join(out) + "\n" # --- Спринт --- @@ -1608,14 +1633,14 @@ def cmd_sprint_drop(lay: Layout, a: argparse.Namespace) -> int: f" в {lay.name('backlog')} (есть: {avail})") new_text = meta_updated(path, section=section, reason=a.reason) if new_text is None: - raise Usage(f"{slug}.md без мета-строки **Секция:** — прогони check и почини") + raise Usage(f"{slug}.md без поля **Секция:** в мете — прогони check и почини") plan.file(path, new_text) insert_entry(backlog_lines, section, sprint_lines.pop(ei)) dropped.append(slug) plan.index(lay, "backlog", backlog_lines) plan.index(lay, "sprint", sprint_lines) plan.commit() - print(f"вышло из спринта: {', '.join(dropped)} — причина записана в мета-строку") + print(f"вышло из спринта: {', '.join(dropped)} — причина записана в мету") print(" живого предложения оставаться не должно; наработки, которые жалко," " переносятся в тело задачи текстом") return EXIT_OK @@ -1660,9 +1685,9 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: однозначно в файле или где источник ровно один: - дубли строк на один файл, рассинхрон заголовка, задача не в своей секции; - - отсутствующая строка индекса — восстанавливается **вместе с хуком** из - мета-строки файла; - - хук, оставшийся только в индексе, — переносится в файл (миграция со + - отсутствующая строка индекса — восстанавливается **вместе с «зачем»** из + меты файла; + - «зачем», оставшееся только в индексе, — переносится в файл (миграция со старого формата: другого экземпляра нет, неоднозначности тоже); - строка в чужом индексе (цель в беклоге, задача в плане) — переносится в домашний. Задача лежит ровно в одном индексе и не в том, выбирать не из @@ -1679,6 +1704,19 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: files: dict[Path, str] = {} dirty: set[str] = set() + # 0. Форма меты. Старая (всё одной строкой через `·`) переписывается + # списком. Правка чисто механическая: поля те же, включая нераспознанные. + for name, task in tasks.items(): + if not task["legacy_meta"]: + continue + upd = meta_updated(task["path"]) + if upd is None: + ambiguous.append(f"{name}: мета одной строкой и без поля **Секция:** —" + f" переписать нечего, чинится руками") + continue + files[task["path"]] = upd + fixed.append(f"{name}: мета переписана списком") + # 1. Дубли строк на один файл — оставляем первую. for kind, lines in idx.items(): seen: set[str] = set() @@ -1694,7 +1732,7 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: out.append(line) idx[kind] = out - # 2. Хук: истина в файле. Если в файле его нет, а в индексе есть — это + # 2. «Зачем»: истина в файле. Если в файле нет, а в индексе есть — это # старый формат, и единственный экземпляр надо спасти в файл. for kind, lines in idx.items(): for i, line in enumerate(lines): @@ -1705,18 +1743,18 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: task = tasks.get(name) if not task: continue - idx_hook = (m.group(3) or "").strip() - if not task["hook"] and idx_hook: - upd = meta_updated(task["path"], hook=idx_hook) + idx_why = (m.group(3) or "").strip() + if not task["why"] and idx_why: + upd = meta_updated(task["path"], why=idx_why) if upd is None: - ambiguous.append(f"{name}: хук только в {lay.name(kind)}," - f" а в файле нет мета-строки — перенести некуда") + ambiguous.append(f"{name}: «зачем» только в {lay.name(kind)}," + f" а в файле нет меты — перенести некуда") continue files[task["path"]] = upd - task["hook"] = idx_hook - fixed.append(f"{name}: хук перенесён из {lay.name(kind)} в мета-строку файла") - if m.group(1) != task["title"] or idx_hook != task["hook"]: - lines[i] = entry_line(lay, task["title"], name[:-3], task["hook"]) + task["why"] = idx_why + fixed.append(f"{name}: «зачем» перенесено из {lay.name(kind)} в мету файла") + if m.group(1) != task["title"] or idx_why != task["why"]: + lines[i] = entry_line(lay, task["title"], name[:-3], task["why"]) fixed.append(f"{lay.name(kind)}: строка синхронизирована с файлом: {name}") dirty.add(kind) @@ -1740,9 +1778,9 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: f" нет в {lay.name(home)} — восстанавливать некуда") continue insert_entry(idx[home], section, entry_line(lay, task["title"], name[:-3], - task["hook"])) + task["why"])) fixed.append(f"{lay.name(home)}: восстановлена строка {name}" - + ("" if task["hook"] else " (в файле нет хука — допиши)")) + + ("" if task["why"] else " (в файле нет «зачем» — допиши)")) dirty.add(home) continue kind = where[0] @@ -1897,7 +1935,7 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int: INDEX_CANDIDATES = ("README.md", "BACKLOG.md", "index.md", "INDEX.md") GRAVEYARD_CANDIDATES = ("CLOSED.md", "REJECTED.md", "DONE.md") -OLD_META = re.compile(r"^\*\*(Приоритет|Секция|Priority|Section):\*\*\s*(.*)$") +OLD_META = re.compile(r"^-?\s*\*\*(Приоритет|Секция|Priority|Section):\*\*\s*(.*)$") QUESTION_HEADINGS = ("что решить", "варианты и цена", "открытый вопрос", "вопрос", "что заблокировано", "рекомендация") @@ -1950,7 +1988,7 @@ def scan_old_backlog(src: Path) -> dict: "old_slug": path.stem, "slug": path.stem, "title": bare, "type": kind, "old_section": old_section or entry.get("section", ""), "section": "", "reason": reason, - "hook": entry.get("hook", ""), "goal": "", + "why": entry.get("why", ""), "goal": "", "in_index": path.name in entries, "translit": translit_ish(path.stem), "questions_heading": qh, @@ -2021,7 +2059,7 @@ def scan_list_file(path: Path) -> dict: found["items"].append({ "old_slug": "", "slug": "", "title": clean[:120], "type": PLAIN_TYPE, "old_section": heading, "section": "", "reason": "", - "hook": "", "goal": "", "in_index": False, "translit": False, + "why": "", "goal": "", "in_index": False, "translit": False, "questions_heading": "", "source": f"{path}:{num}", "body": text, "done": state.lower() == "x", }) @@ -2232,7 +2270,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: f" ({', '.join(sections)})") if it.get("goal") and it["goal"] not in goal_slugs: problems.append(f"{slug}: цель «{it['goal']}» не заведена в карте") - for e in (bad_hook(it.get("hook")), bad_reason(it.get("reason"))): + for e in (bad_why(it.get("why")), bad_reason(it.get("reason"))): if e: problems.append(f"{slug}: {e}") if problems: @@ -2250,14 +2288,14 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: renames: dict[str, str] = {} for g in pl.get("goals", []): title = f"[{GOAL}] {g['title']}" - meta = build_meta(g["section"].lower(), g.get("reason", ""), g.get("hook", ""), + meta = build_meta(g["section"].lower(), g.get("reason", ""), g.get("why", ""), g.get("tags", [])) body = g.get("body", "").strip() wr.file(lay.items / f"{g['slug']}.md", f"# {title}\n\n{meta}\n\n{body}\n\n## Завершение\n\n" f"\n") insert_entry(plan_lines, g["section"].lower(), - entry_line(lay, title, g["slug"], g.get("hook", ""))) + entry_line(lay, title, g["slug"], g.get("why", ""))) for it in pl.get("items", []): slug = it.get("slug") or it["old_slug"] @@ -2285,10 +2323,10 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: body, count=1, flags=re.M | re.I) if QUESTION_TAG not in tags: tags.append(QUESTION_TAG) - meta = build_meta(it["section"].lower(), it.get("reason", ""), it.get("hook", ""), tags) + meta = build_meta(it["section"].lower(), it.get("reason", ""), it.get("why", ""), tags) wr.file(lay.items / f"{slug}.md", f"# {title}\n\n{meta}\n\n{body}\n") insert_entry(backlog_lines, it["section"].lower(), - entry_line(lay, title, slug, it.get("hook", ""))) + entry_line(lay, title, slug, it.get("why", ""))) if pl.get("rejected"): head = init_files(lay, sections, plan_sections, {})[lay.index("rejected")] @@ -2364,7 +2402,7 @@ def main() -> int: p = sub.add_parser("check", help="согласованность файлов и индексов") p.add_argument("--dir") p.add_argument("--fix", action="store_true", - help="починить безопасный дрейф (секция, заголовок, дубли, хук, дом)") + help="починить безопасный дрейф (секция, заголовок, дубли, «зачем», форма меты)") p = sub.add_parser("list", help="список задач и целей") p.add_argument("--dir") @@ -2384,14 +2422,14 @@ def main() -> int: p.add_argument("--type", choices=TYPES) p.add_argument("--section") p.add_argument("--goal", help="слаг цели → тег goal:<слаг>") - p.add_argument("--hook") + p.add_argument("--why") p.add_argument("--reason") p.add_argument("--tag") - p = sub.add_parser("edit", help="сменить заголовок/хук/тип/цель/теги") + p = sub.add_parser("edit", help="сменить заголовок/«зачем»/тип/цель/теги") p.add_argument("slug") p.add_argument("--title") - p.add_argument("--hook") + p.add_argument("--why") p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE)) p.add_argument("--goal", help="заменить тег goal:<слаг>") p.add_argument("--add-tag", dest="add_tag") @@ -2417,7 +2455,7 @@ def main() -> int: p = sub.add_parser("reopen", help="вернуть закрытую задачу (приёмка не сошлась)") p.add_argument("slug") - p.add_argument("--reason", help="почему возвращена — уедет в мета-строку") + p.add_argument("--reason", help="почему возвращена — уедет в мету") p.add_argument("--dir") p = sub.add_parser("sprint", help="операции спринта")