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