From 0c8390d774c89fc5ca0ae5cc2f2cb325864f1e24 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Tue, 4 Aug 2026 19:06:54 +0300 Subject: [PATCH] =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B8:=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=BA=20=D0=BE=D1=82=D0=B2=D0=B5=D1=87=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BD=D0=B0=20=D0=B2=D0=BE=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=20=D1=81=D0=B2=D0=BE=D0=B5=D0=B3=D0=BE=20=D1=82=D0=B8?= =?UTF-8?q?=D0=BF=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обкатка скилла tasks на выдуманном проекте — консольные крестики-нолики на JavaScript, каталог заведён с нуля тем же скриптом. Форма вылезла раньше содержания, и правки все про неё. Заголовок отвечает на вопрос типа записи, и форм три: цель — утверждение о возможности, задача — глагол в неопределённой форме (допускается «не» перед ним), идея — назывное, без обещания. Причина не стилистическая: описательный заголовок называет состояние, а из состояния не видно, чего от работы ждут — «Ничья объявляется, пока клетки есть» одинаково читается как жалоба и как задание. Отсюда же разница индексов: роадмап — список возможностей, беклог — список работ, и перепутанные формы делают каждый похожим на другой. Механизировано ровно то, что механизируется: check считает заголовки, где первое слово не на -ть/-ти/-чь, и печатает число в блоке здоровья. Замечанием на файл нельзя — эвристика грубая, а на 97 записях двух живых проектов это поток одинаковых строк, после которого пропускают весь блок. Годность формулировки судит отдельный агент task-wording, а не чек-лист в скилле: сейчас формулировку пишет и проверяет один агент в одном контексте, а самопроверка текста слабее всего там, где формулировка казалась удачной при написании. Он ничего не правит — возвращает готовые формулировки, и заголовок с «зачем» показываются человеку, потому что по ним задачу выбирают. Ничего из того, что ловит tasks.py check, он не трогает намеренно: это был бы второй дом для правила. Заголовки секций — с прописной, после заголовка пустая строка, во всех индексах. Канонические имена стали Готово | Запланировано | Направления | Разработка (англ. Done | Planned | Directions | Tooling), сверка везде по нижнему регистру, так что старые индексы читаются по-прежнему. Отбивка живёт на записи, а не на вставке: через Plan.index проходит каждая правка индекса, а мест вставки три. Имя секции принадлежит заголовку индекса, файл на неё только ссылается. Это разрешает единственную неоднозначность починки — расхождение в одном регистре правится в пользу заголовка. Без него переезд на канон оставил бы «Готово» в роадмапе и «готово» в каждом файле цели, и свести это было бы некому. Регистр правится только у канонических секций: имена секций беклога выбирает проект. Обкатка нашла два дефекта, которых не находили ни линтеры, ни свои проверки. Вставка в пустую секцию съедала отбивку перед следующим заголовком — пропуск пустых строк теперь идёт только до первой непустой. Мета, разорванная пустой строкой, теряла поля молча: check видел лишь следствие («без рода работы») и советовал edit --kind, который дописывал второе такое же поле. Поле меты в теле стало ошибкой с названной причиной, и --fix её намеренно не чинит — какое из двух значений верное, знает человек. DECISIONS тема 20 (ЕЕЕ–ККК, следствия 82–85), changelog канона v3 пополнен двумя пунктами и двумя шагами переезда, TODO — два шага для healthlog и jellybit. Co-Authored-By: Claude Opus 5 (1M context) --- DECISIONS.md | 103 +++++++++-- README.md | 3 +- TODO.md | 14 +- av-dev-pm/agents/task-wording.md | 116 ++++++++++++ .../skills/canon/references/changelog.md | 40 +++-- av-dev-pm/skills/init/SKILL.md | 2 +- .../skills/session/references/cadence.md | 6 +- av-dev-pm/skills/tasks/SKILL.md | 82 +++++++-- av-dev-pm/skills/tasks/references/adopt.md | 2 +- .../skills/tasks/references/from-review.md | 2 +- .../skills/tasks/references/task-format.md | 28 ++- av-dev-pm/skills/tasks/scripts/tasks.py | 170 +++++++++++++++--- 12 files changed, 493 insertions(+), 75 deletions(-) create mode 100644 av-dev-pm/agents/task-wording.md diff --git a/DECISIONS.md b/DECISIONS.md index 7923672..a541266 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1407,27 +1407,27 @@ SSS: рубрика на узел без нового понятия порож словаре, тесте готовности, автомате переходов, `split.md` и трёх местах `tasks.py`. -**ГГГ. Имена секций роадмапа — `готово` / `запланировано` / `направления` / -`разработка`.** Первый набор (`умеет` / `строим` / `станок`) прожил один заход и +**ГГГ. Имена секций роадмапа — `Готово` / `Запланировано` / `Направления` / +`Разработка`.** Первый набор (`умеет` / `строим` / `станок`) прожил один заход и был признан неудачным. Из четырёх предложенных имён отвергнуто одно, и по проверяемой причине: **`окружение` уже занято** — в `architecture.md` это боевое окружение приложения, «где работает, что рядом, кто перезапускает», и одно слово -в двух смыслах развело бы документы канона. Взято `разработка`. +в двух смыслах развело бы документы канона. Взято `Разработка`. -Принятый компромисс назван вслух: `готово` слегка тянет обратно в трекерную рамку +Принятый компромисс назван вслух: `Готово` слегка тянет обратно в трекерную рамку «состояние работы», тогда как секция про **возможность**. Перевесила читаемость с первого взгляда, а смысл несут заголовки целей внутри секции. Так же принято, что -цель в `запланировано` может быть уже наполовину построена: это очередь, а не +цель в `Запланировано` может быть уже наполовину построена: это очередь, а не «не начато», а «в работе» живёт в `SPRINT.md`. **ДДД. Секции роадмапа канонические, секции беклога — нет.** Разница выведена, а не назначена: у секций роадмапа есть **семантика** (достигнутое, очередь, долгое, не про продукт), в первую пишет сам `close`, и роадмап, названный по-своему, -читался бы только своим автором. Секции беклога (`ядро`, `инфра`) семантики не +читался бы только своим автором. Секции беклога (`Ядро`, `Инфра`) семантики не несут — это полки. Поэтому `check` проверяет у роадмапа три вещи: состав закреплён (чужая секция — ошибка), все четыре обязаны быть, язык один на весь индекс; -`--roadmap-sections` у `init` упразднён. Английский набор — `done` | `planned` | -`directions` | `tooling`. +`--roadmap-sections` у `init` упразднён. Английский набор — `Done` | `Planned` | +`Directions` | `Tooling`. Проверено на том самом случае, ради которого правило и заводилось: секция «Что уже пройдено», которую healthlog вёл руками, теперь называется ошибкой поимённо. @@ -1442,8 +1442,91 @@ SSS: рубрика на узел без нового понятия порож 80. **Прозаический раздел в индексе — дрейф.** Любой `##` проверка считает секцией, поэтому «Что уже пройдено» и «Почему в таком порядке» в healthlog формально были двумя лишними секциями, куда могла уехать задача. При - повышении они разбираются: звенья — строками в `умеет`, обоснование очереди — - прозой внутри `строим`. + повышении они разбираются: звенья — строками в `Готово`, обоснование очереди — + прозой внутри `Запланировано`. 81. **Правил стало пять, и нулевое — про смысл, а не про механику.** «Цель — возможность, задача — шаг к ней» стоит перед правилами о гниении беклога и производности индексов, потому что из него следует, зачем эти механики нужны. + +## 20. Форма записи: заголовок, секции, вычитка (2026-08-04) + +### Что было + +Обкатка обновлённого скилла на выдуманном проекте — консольные крестики-нолики +на JavaScript. Каталог задач заведён с нуля тем же скриптом: шесть целей, девять +задач, отказ, достижение цели, спринт. Смотрели три вещи: тексты, разделы, состав +задач. + +Форма вылезла раньше содержания. Индексы вышли с секциями со строчной буквы и без +отбивки после заголовка — читается как список списков, а не как документ. А все +заголовки задач оказались **описательными**: «Лишние символы в ходе молча +отбрасываются», «Поле печатается одним куском кода», «Линтер и тесты гоняются +одной командой». Правило «задача отвечает на «что для этого нужно сделать»» в +скилле стояло с самого начала — но относилось к содержанию задачи, а не к её +заголовку, и потому не применялось там, где заголовок и есть всё, что видно в +списке. + +### Решено + +**ЕЕЕ. Заголовок отвечает на вопрос своего типа, и форм три.** Цель — утверждение +о возможности («Соперником может быть компьютер»); задача — глагол в +неопределённой форме, допускается «не» перед ним («Не отбрасывать молча лишние +символы в ходе»); идея — назывное, без обещания. Причина не стилистическая: +описательный заголовок называет **состояние**, а из состояния не видно, чего от +работы ждут — «Ничья объявляется, пока клетки есть» одинаково читается как жалоба +и как задание. В списке, где решают «брать или не брать», это разные вещи. + +Отсюда же разница индексов: роадмап — список возможностей, беклог — список работ. +Перепутанные формы заголовков делают каждый из них похожим на другой. + +**ЖЖЖ. Механизировано ровно то, что механизируется, — счётчиком, а не +замечанием.** `check` считает заголовки, у которых первое слово не оканчивается +на `-ть`/`-ти`/`-чь` (перед ним допускается «не»), и печатает **число** в блоке +здоровья. Замечанием на файл этого делать нельзя: проверка эвристическая, а +беклог, заведённый до правила, переоформляют не «заодно» — десятки одинаковых +строк научили бы пропускать весь блок. + +**ЗЗЗ. Годность формулировки судит отдельный агент `task-wording`, а не чек-лист +в скилле.** Самопроверка текста слабее всего там, где формулировка казалась +удачной при написании, — а пишет и проверяет иначе один и тот же агент в одном +контексте. Агент читает пачку записей и возвращает **готовые формулировки на +замену**, ничего не правя сам; заголовок и «зачем» подставляются командой и +показываются человеку, потому что именно по ним задачу выбирают. Он намеренно не +проверяет ничего из того, что ловит `tasks.py check`: повторить машинную проверку +словами значит завести правилу второй дом. + +**ИИИ. Заголовок секции — с прописной, после него пустая строка.** Во всех +индексах, включая секции беклога, имена которых выбирает проект: правило про +**оформление**, а не про имя. Канонические имена стали писаться с прописной +(`Готово` | `Запланировано` | `Направления` | `Разработка`, англ. `Done` | +`Planned` | `Directions` | `Tooling`), сверка везде идёт по нижнему регистру, так +что старые индексы читаются по-прежнему и поднимаются `check --fix`. + +**ККК. Имя секции принадлежит заголовку индекса, файл на неё только ссылается.** +Это разрешает единственную неоднозначность починки: расхождение файла и заголовка +**в одном регистре** правится в пользу заголовка. Без этого шага переезд на канон +оставил бы `Готово` в роадмапе и `готово` в каждом файле цели — расхождение +безвредное, но вечное, потому что свести его было бы некому. + +### Что из этого следует + +82. **Отбивка живёт на записи, а не на вставке.** `spaced_sections` вызывается в + `Plan.index`, через который проходит **каждая** запись индекса. Чинить + отбивку в каждом месте вставки значило бы полагаться на то, что ни одного не + забыли, — а мест вставки три (`--first`, `--after`, в конец). +83. **Обкатка нашла два дефекта, которых не нашли ни линтеры, ни свои проверки.** + Вставка в пустую секцию съедала отбивку перед следующим заголовком; мета, + разорванная пустой строкой, теряла поля молча, а `check` видел только + следствие («без рода работы») и советовал `edit --kind`, который дописывал + **второе** такое же поле. Оба класса теперь названы: пропуск пустых строк + идёт только до первой непустой, а поле меты в теле — ошибка с названной + причиной, которую `--fix` намеренно не чинит. +84. **Пустой проект показывает форму хуже живого.** Чтобы увидеть достигнутую + цель, отказ, спринт и все четыре рода работы, проект пришлось поставить на + середину пути. Это довод в пользу того, чтобы обкатку вести на *состоянии*, а + не на *старте*: у старта половина формы не наблюдаема. +85. **Мелкая цель даёт две задачи, и это не повод её укрупнять.** У цели + «Соперником может быть компьютер» третья задача напрашивалась (выбор уровня + соперника), но не мерджится порознь: без сильного соперника выбирать не из + чего. Тест декомпозиции её отбил — частичный ответ на вопрос «не выродились + ли цели в ярлыки тем». diff --git a/README.md b/README.md index b56734a..3921605 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ - `docs` — содержимое канона по ходу разработки: ADR из архивного `design.md`, промоут конвенций, запись в разведку и журнал ревью, чистка архитектуры; - - `tasks` — задачи и цели каталогом markdown-файлов; + - `tasks` — задачи и цели каталогом markdown-файлов; вычитку формулировок + ведёт отдельный агент `task-wording`; - `session` — ритуал между спринтами и ведение спринта. - **av-dev-pipeline** — исполнение. **Требует OpenSpec.** - `task-pipeline` — задача через полный цикл SDD, от постановки до коммита; diff --git a/TODO.md b/TODO.md index d4617e2..d62f621 100644 --- a/TODO.md +++ b/TODO.md @@ -167,11 +167,17 @@ jellybit 43. Шаги повышения — [changelog.md](av-dev-pm/skills/can - [ ] род работы и раздел «Затрагивает» — **не задним числом**: сперва то, что идёт в ближайший набор (`sprint take` без них откажет), остальное по ходу переоценки (PPP) -- [ ] секции роадмапа: `порядок` → `запланировано`, `темы` → `направления`, - завести `готово` и `разработка`; прозаические разделы healthlog («Что уже +- [ ] секции роадмапа: `порядок` → `Запланировано`, `темы` → `Направления`, + завести `Готово` и `Разработка`; прозаические разделы healthlog («Что уже пройдено», «Почему в таком порядке») разложить — звенья строками в - `готово`, обоснование очереди прозой внутри `запланировано` (тема 19, 80). + `Готово`, обоснование очереди прозой внутри `Запланировано` (тема 19, 80). `check` теперь называет чужую секцию ошибкой, так что шаг обязателен - [ ] переформулировать цели ответом на «что приложение будет уметь»; цели не про приложение («Процесс и качество разработки» в jellybit) — в - `разработка` + `Разработка` +- [ ] `check --fix` на обоих: поднимет написание канонических секций, поставит + отбивку после заголовков и сведёт секцию в мете файлов с заголовками. + Секции беклога переименовать руками — имена выбирал проект (тема 20, ККК) +- [ ] заголовки задач в форму действия — **не задним числом**: по мере попадания + задачи в работу. `check` печатает их число, `task-wording` предложит + формулировки пачкой (тема 20, ЕЕЕ) diff --git a/av-dev-pm/agents/task-wording.md b/av-dev-pm/agents/task-wording.md new file mode 100644 index 0000000..cf72629 --- /dev/null +++ b/av-dev-pm/agents/task-wording.md @@ -0,0 +1,116 @@ +--- +name: task-wording +description: "Вычитка формулировок задач, целей и идей: форма заголовка по типу записи (цель — что приложение будет уметь, задача — что нужно сделать, идея — о чём она), англицизм при живом русском слове, термин, которого нет в документах проекта, «зачем», пересказывающее заголовок вместо состояния и боли, «Затрагивает» с замыслом вместо границ, критерий приёмки с оракулом только на словах. Отдаёт готовые формулировки на замену и ничего не правит сам. Использовать после заведения или разбора пачки записей и на переоценке. Только чтение." +tools: Read, Grep, Glob +model: sonnet +color: green +--- + +Ты — **вычитка формулировок** каталога задач. Оптика — язык записи, а не работа, +которую она описывает: ты не судишь, нужна ли задача, правильно ли выбрана цель и +достаточно ли её декомпозиции. + +Ты **ничего не правишь**. Каждая находка — готовая формулировка на замену, +которую зовущий подставит командой (`edit <слаг> --title …`, `--why …`) или +впишет в тело. Файлы ты только читаешь. + +## Что тебе дают + +Список файлов записей (`items/.md`) или каталог задач целиком. Плюс, если +зовущий их назвал, документы проекта — паспорт, архитектура, конвенции: по ним +проверяется, известен ли термин. **Не назвали — считай известными только те +слова, что встречаются в других записях того же каталога**, и говори об этом в +границах покрытия. + +## Правила + +Проверяешь семь, и у каждого своя причина — она объясняет, где правило **не** +применяется. + +1. **Форма заголовка по типу записи.** + + | Тип | Отвечает на | Форма | + | --- | --- | --- | + | `[goal]` | что приложение будет уметь | утверждение о возможности: «Соперником может быть компьютер» | + | задача | что нужно сделать | глагол в неопределённой форме, допускается «не» перед ним: «Печатать поле одним куском кода» | + | `[idea]` | о чём она | назывное, без обещания: «Подсказка следующего хода» | + + Описательный заголовок задачи («Лишние символы молча отбрасываются») называет + **состояние** и одинаково читается как жалоба и как задание. Заголовок цели в + форме действия («Сделать соперника-компьютер») превращает роадмап в список + работ — а он список возможностей. + + **Область работ — не цель.** «Работа со слиянием», «Рефакторинг вывода» не + отвечают ни на один из трёх вопросов; предложи возможность, которую эта + работа создаёт, и скажи, если из текста её не видно. + +2. **«Зачем» отвечает на «зачем нужна эта задача»** — состояние, остаток, боль, + — а не пересказывает заголовок. «Починить разбор хода» при заголовке «Не + отбрасывать молча лишние символы» — пересказ: читающий узнаёт то же самое + дважды и по-прежнему не знает, почему это лежит в беклоге. + +3. **Англицизм, у которого есть живое русское слово, заменяется.** Не + «зафиксить флоу», а «починить порядок доставки»; не «отрефакторить», а + «убрать второй путь приёма». **Не трогай** то, что является именем вещи: слаг, + имя пакета, команда, тип в коде, устоявшийся термин предметной области. + +4. **Термин, которого нет в документах проекта, вводится одной строкой или не + употребляется.** Заменять его своей догадкой нельзя: ты не знаешь предметную + область. Пиши «термин «X» не встречается ни в документах, ни в других + записях — введи строкой или назови известным словом». + +5. **«Затрагивает» перечисляет границы, а не замысел.** Граница — то, у чего есть + внешняя сторона: команда и её аргументы, эндпоинт, таблица и миграция, формат + на диске, публичный тип пакета, внешний сервис. «Переписать хранилище на новый + драйвер» — замысел; проверяется вопросом «это можно назвать до того, как + решено *как* делать?». Свойства репозитория (номер миграции, версия + зависимости, хеш) — тоже находка: они протухают молча. + +6. **У критерия назван оракул, и оракул проверяем.** «Оракул: глазами» на + утверждение, которого глазами не проверить («компьютер не проигрывает ни в + одной партии»), — находка: слово стоит, проверки нет. Число критериев считает + `check`, тебе оно неинтересно. + +7. **Предписания процесса в теле нет.** «Делать профилем standard», «взять такой-то + агент» — это выбор, который делают, увидев изменение, а не при постановке. + +## Чего ты не проверяешь + +Всё, что ловит `tasks.py check`: состав и написание секций, наличие разделов, +число критериев, теги, согласованность индексов, битые ссылки. Повторять +машинную проверку словами — заводить второй дом для одного правила; если видишь +такое, просто не пиши. + +Не проверяешь и **содержание работы**: нужна ли задача, верно ли выбрана цель, +не крупна ли она. Это разбор, а не вычитка. + +## Порог вмешательства + +**Правка без нарушенного правила не пишется.** Список, в котором половина — +вкусовые переформулировки, перестают читать целиком, и вместе с ним пропадают +настоящие находки. Сомневаешься — не пиши. Формулировка, которая просто **не +твоя**, — не находка. + +Одна запись может дать несколько находок, но заголовок правится один раз: не +предлагай два варианта на выбор, предлагай лучший. + +## Доклад + +Находки по одной, в порядке важности (заголовок → «зачем» → границы → критерии → +язык): + +``` +<файл> + правило: <номер и короткое имя> + сейчас: <как написано> + предложение: <готовая формулировка, подставляемая как есть> + почему: <одна фраза> +``` + +В конце — **границы покрытия**: сколько записей просмотрено из скольких, какие +не смотрел и почему, и по чему проверялись термины (документы проекта названы +или нет). Отчёт без этой строки читается как «беклог вычитан», не сообщая, какая +его часть осталась нетронутой. + +Ничего не нашёл — так и скажи одной строкой. Пустой доклад с границами покрытия +полезнее выдуманной находки. diff --git a/av-dev-pm/skills/canon/references/changelog.md b/av-dev-pm/skills/canon/references/changelog.md index b99e99e..085d2a3 100644 --- a/av-dev-pm/skills/canon/references/changelog.md +++ b/av-dev-pm/skills/canon/references/changelog.md @@ -32,20 +32,29 @@ upgrade` идёт по записям снизу вверх от версии п касается (эндпоинт, таблица и миграция, формат на диске, публичный тип). Как и критерии приёмки, требуется к взятию в спринт, а не к заведению. 3. **Секции роадмапа** — четыре вместо двух и **канонические**, в отличие от - секций беклога: `готово` (достигнутые цели строкой с датой, без ссылки на - файл), `запланировано` (очередь значима), `направления` (очереди нет), - `разработка` (инструмент и процесс, не возможности приложения). Английский - вариант — `done` | `planned` | `directions` | `tooling`, один язык на весь + секций беклога: `Готово` (достигнутые цели строкой с датой, без ссылки на + файл), `Запланировано` (очередь значима), `Направления` (очереди нет), + `Разработка` (инструмент и процесс, не возможности приложения). Английский + вариант — `Done` | `Planned` | `Directions` | `Tooling`, один язык на весь индекс. Переименованию проектом не подлежат: у каждой свой смысл, и в первую пишет сам `close`; `tasks.py check` проверяет состав. -4. **Умолчание профиля ревью сменилось** — это не раскладка, но проектный текст +4. **Форма заголовка записи** — по типу: задача отвечает на «что нужно сделать» + и пишется глаголом в неопределённой форме («Не отбрасывать молча лишние + символы»), цель — на «что приложение будет уметь», идея просто называет, о + чём она. `check` считает заголовки не в форме действия и печатает число в + блоке здоровья. Годность формулировки — не машине: её смотрит новый агент + `task-wording` (вычитка формулировок, только чтение). +5. **Заголовки секций — с прописной, после заголовка пустая строка**, во всех + индексах. Написание канонических секций и отбивку правит `check --fix`; он + же сводит написание секции в мете файла с заголовком индекса. +6. **Умолчание профиля ревью сменилось** — это не раскладка, но проектный текст под него уже написан. `standard` стал рабочим умолчанием: миграция схемы, публичный контракт и инвариант ступень больше **не** поднимают, `wide` означает новое понятие или структурную единицу. Подраздел «Триггеры профиля» в `docs/review.md` остаётся на месте, но его содержимое надо перечитать. **Что переехало:** `docs/tasks/PLAN.md` → `docs/tasks/ROADMAP.md`; достигнутая -цель — из небытия в секцию `готово`: `close <цель> --implemented` удаляет файл, но +цель — из небытия в секцию `Готово`: `close <цель> --implemented` удаляет файл, но **оставляет строку с датой**. Прежде роадмап отвечал только «что осталось», и половину его вопроса вели прозой руками. Вместе с файлом переименован ключ конфига `tasks.plan` → `tasks.roadmap` и токены @@ -76,19 +85,26 @@ upgrade` идёт по записям снизу вверх от версии п что для этого проекта считается **новым понятием** и **правилом идентичности**, — и убрать остальное, иначе проект возвращает себе прежнюю частоту полного набора уточнением. -7. Переименовать секции роадмапа: `порядок` → `запланировано`, `темы` → - `направления`; завести `готово` **первой** и `разработка` последней. +7. Переименовать секции роадмапа: `порядок` → `Запланировано`, `темы` → + `Направления`; завести `Готово` **первой** и `Разработка` последней. Прозаические разделы вроде «Что уже пройдено», которые велись руками, - разложить: звенья — строками в `готово` (дата, слаг, что стало возможно), - обоснование очереди оставить прозой в `запланировано`. Любой `##` в индексе + разложить: звенья — строками в `Готово` (дата, слаг, что стало возможно), + обоснование очереди оставить прозой в `Запланировано`. Любой `##` в индексе проверка считает секцией, и теперь `check` называет чужую секцию ошибкой. 8. Переформулировать цели ответом на **«что приложение будет уметь»**: не «Работа со слиянием», а «Исход слияния не зависит от порядка доставки». Свойство поведения — законная цель. Цель, которая не про приложение - (процесс, инструмент), переезжает в `разработка`. + (процесс, инструмент), переезжает в `Разработка`. 9. `[epic]`, если он в проекте заводился: это либо цель, либо набор задач под общей целью. `check` назовёт его неизвестным типом. -10. `docs/.pm.json`: `"canon": 3`. +10. Прогнать `python3 tasks.py check --dir docs/tasks --fix`: он поднимет + написание канонических секций, поставит отбивку после заголовков и сведёт + секцию в мете файлов с заголовками индексов. Секции беклога проект + переименовывает сам — их имена он выбирал, и трогать их скрипт не вправе. +11. Переписать заголовки задач в форму действия — по мере того, как задача + попадает в работу, а не «заодно»: `check` печатает их число, а `task-wording` + предложит формулировки на замену пачкой. +12. `docs/.pm.json`: `"canon": 3`. ## Версия 2 — 2026-08-03 diff --git a/av-dev-pm/skills/init/SKILL.md b/av-dev-pm/skills/init/SKILL.md index 695a36e..cd9a29e 100644 --- a/av-dev-pm/skills/init/SKILL.md +++ b/av-dev-pm/skills/init/SKILL.md @@ -49,7 +49,7 @@ description: "Завести новый проект — сессия вопро 5. **Чем краснеет гейт.** Какие проверки обязательны; что красит безусловно; чего в гейте намеренно не будет и кто тогда это гоняет. 6. **Первые цели.** Возможности приложения, а не задачи: три-пять целей в - `запланировано`, каждая — ответ на «что приложение будет уметь», с + `Запланировано`, каждая — ответ на «что приложение будет уметь», с обоснованием очереди прозой. ### Как вести diff --git a/av-dev-pm/skills/session/references/cadence.md b/av-dev-pm/skills/session/references/cadence.md index 41620a6..4d5921e 100644 --- a/av-dev-pm/skills/session/references/cadence.md +++ b/av-dev-pm/skills/session/references/cadence.md @@ -175,9 +175,9 @@ ## Шаг 4. Выбор цели и набор спринта -1. **Покажи состояние проекта**: секцию `готово` (что приложение уже умеет — - это половина ответа на «где мы»), затем `запланировано` с обоснованием - очереди, `направления`, и +1. **Покажи состояние проекта**: секцию `Готово` (что приложение уже умеет — + это половина ответа на «где мы»), затем `Запланировано` с обоснованием + очереди, `Направления`, и по каждой цели-кандидату — сколько под ней задач без открытых вопросов (`list --goal <слаг>`). Цель без готовых задач набором не станет: её сперва надо декомпозировать. diff --git a/av-dev-pm/skills/tasks/SKILL.md b/av-dev-pm/skills/tasks/SKILL.md index 2bbf715..03000e3 100644 --- a/av-dev-pm/skills/tasks/SKILL.md +++ b/av-dev-pm/skills/tasks/SKILL.md @@ -71,22 +71,28 @@ docs/tasks/ | Секция | Англ. | Что в ней | | --- | --- | --- | -| `готово` | `done` | достигнутые цели — строкой с датой, **без ссылки на файл**: файл удалён, поведение живёт в спеках | -| `запланировано` | `planned` | очередь значима и обосновывается прозой рядом | -| `направления` | `directions` | очереди нет, тянутся долго | -| `разработка` | `tooling` | инструмент и процесс — не возможности приложения, и потому отдельно | +| `Готово` | `Done` | достигнутые цели — строкой с датой, **без ссылки на файл**: файл удалён, поведение живёт в спеках | +| `Запланировано` | `Planned` | очередь значима и обосновывается прозой рядом | +| `Направления` | `Directions` | очереди нет, тянутся долго | +| `Разработка` | `Tooling` | инструмент и процесс — не возможности приложения, и потому отдельно | **Секции роадмапа канонические, секции беклога — нет**, и разница не в любви к единообразию. У каждой секции роадмапа свой смысл, в первую пишет сам `close`, и роадмап, названный по-своему, читался бы только своим автором. Секции беклога -(`ядро`, `инфра`) смысла не несут — это полки, и остаются делом проекта. +(`Ядро`, `Инфра`) смысла не несут — это полки, и остаются делом проекта. Отсюда три правила, которые проверяет `tasks.py check`: **состав закреплён** (чужая секция — ошибка, а не вольность), **все четыре обязаны быть** (нет секции — нет ответа на её часть вопроса), **язык один на весь индекс**. `--roadmap-sections` у `init` нет: выбирать нечего. -Оговорка про `разработка`: слово `окружение` сюда не годится — в +**Заголовок секции — с прописной, после него пустая строка.** Во всех индексах +одинаково, включая секции беклога, которые проект называет сам. Написание +канонических секций правит `check --fix` (заодно и ссылку на секцию в мете +файлов: имя секции принадлежит заголовку индекса, файл на неё только +ссылается); отбивку он ставит везде. + +Оговорка про `Разработка`: слово `окружение` сюда не годится — в `architecture.md` оно уже значит боевое окружение приложения, и одно слово в двух смыслах развело бы документы канона. @@ -110,7 +116,7 @@ docs/tasks/ всех наборов без отдельного журнала. **У достигнутой цели запись остаётся, и это единственное исключение.** Файл -удаляется так же, а строка переезжает в секцию `готово` с датой. Причина в том, +удаляется так же, а строка переезжает в секцию `Готово` с датой. Причина в том, что цель — не работа, а **возможность**: «что приложение умеет» это половина вопроса, ради которого роадмап и открывают, и стирать её вместе с файлом значит оставить инструмент, отвечающий только «что осталось». Вторым домом это не @@ -172,9 +178,9 @@ stateDiagram-v2 отведена отдельная секция роадмапа, чтобы они были видны в том же экране и при этом не читались как возможности продукта. -Секция выбирается так: очередь значима и обоснована прозой — `запланировано`; -тянется долго и очереди не имеет — `направления`; не про приложение — -`разработка`; в `готово` кладёт сам `close`. +Секция выбирается так: очередь значима и обоснована прозой — `Запланировано`; +тянется долго и очереди не имеет — `Направления`; не про приложение — +`Разработка`; в `Готово` кладёт сам `close`. - **Список задач цели выводится, а не хранится.** В теле цели — зачем она и что считается её завершением; перечня задач там нет. Он был бы третьим индексом и @@ -184,7 +190,7 @@ stateDiagram-v2 - **Статус цели выводится.** Цель достигнута, когда у неё не осталось открытых задач; `[x]`/`[~]` руками не ведутся, а `close` цели с живыми задачами скрипт запретит. Достижение — `close <цель> --implemented`: файл удаляется, - строка с датой переезжает в `готово`. Ошиблись — `reopen` вернёт файл и + строка с датой переезжает в `Готово`. Ошиблись — `reopen` вернёт файл и **снимет строку достигнутого**, чтобы роадмап не утверждал того, чего нет. Единственная оговорка: цель без задач неотличима — «ещё не разобрана» или «всё закрыто». Различает **тег `decomposed`** в мете цели: он ставится, когда цель разложена на задачи. Тег, а не строка в теле — @@ -248,8 +254,34 @@ stateDiagram-v2 ## Как написана задача -Два требования к тексту, и оба про то, чтобы задачу можно было **оценить, не -открывая код**. +Три требования к тексту. Первое — про заголовок, два остальных про то, чтобы +задачу можно было **оценить, не открывая код**. + +**Заголовок отвечает на вопрос своего типа.** Вопросов три, поэтому и форм три: + +| Тип | Отвечает на | Пример | +| --- | --- | --- | +| цель | что приложение будет уметь | Соперником может быть компьютер | +| задача | что нужно сделать | Печатать поле одним куском кода | +| идея | о чём она | Подсказка следующего хода | + +Задача — **глаголом в неопределённой форме**, перед ним допускается «не»: «Не +отбрасывать молча лишние символы в ходе», а не «Лишние символы молча +отбрасываются». Описательный заголовок называет **состояние**, а из состояния не +видно, чего от работы ждут: «Ничья объявляется, пока клетки есть» одинаково +читается и как жалоба, и как задание, — и в списке, где решают «брать или не +брать», это разные вещи. Идея формы действия не несёт **намеренно**: что делать, +ещё неизвестно, и заголовок-действие обещал бы решённость, которой нет. + +Из этого же правила растёт разница индексов: роадмап — список возможностей, +беклог — список работ, и если заголовки перепутать формами, каждый из них +начинает читаться как другой. + +`check` считает заголовки не в форме действия и печатает **число** в блоке +здоровья, не замечанием на файл: проверка эвристическая (первое слово на +`-ть`/`-ти`/`-чь`), а беклог, заведённый до правила, не переоформляют «заодно». +Годность формулировки — не машине: её смотрит +[агент вычитки](#вычитка-формулировок). **Функции и границы, а не намерения.** Задача называет, что система начнёт делать, и какие границы это трогает: эндпоинт или команду, таблицу и миграцию, @@ -428,6 +460,28 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап обе половины остаются в одной ступени, потому что несокращаемый костяк проверок платится за каждую задачу отдельно. +### Вычитка формулировок + +Язык записей судит **отдельный проход** — агент `task-wording`, а не тот же +агент, который их только что написал: самопроверка текста слабее всего ровно +там, где формулировка казалась удачной при написании. + +Зовётся он **пачкой, а не на каждую запись**: после заведения нескольких задач, +после разбора находок ревью и на переоценке. Ему передаётся список файлов и — +если есть — паспорт, архитектура и конвенции проекта: по ним он отличает +неизвестный термин от известного. + +Он ничего не правит. Возвращает готовые формулировки, и их подставляет скилл: +заголовок — `edit <слаг> --title …`, «зачем» — `edit <слаг> --why …`, остальное +редактором. **Заголовок и «зачем» — это то, по чему задачу выбирают, поэтому +менять их молча нельзя**: покажи предложенное пользователю вместе с тем, что +было. Правки в теле (границы, критерии, язык) применяются сразу. + +Что он смотрит и чего не смотрит — в его уставе; коротко: форму заголовка по +типу записи, «зачем» вместо пересказа, англицизмы, неизвестные термины, границы +вместо замысла, годность оракулов, предписания процесса. Всё, что ловит +`tasks.py check`, он не трогает намеренно. + ### Гигиена полей Правится по ходу любой операции, которая задачи касается (но не «заодно» по @@ -479,7 +533,7 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап канон, а не по одному на каталог. Неизвестный ключ — код 3 на любой команде, так что лишнее слово в этом объекте останавливает работу с задачами целиком. - **Секции беклога** берутся из заголовков `##` индекса как есть; их количество - и названия — дело проекта (умолчание `ядро` / `инфра`). **В конфиге их нет** — + и названия — дело проекта (умолчание `Ядро` / `Инфра`). **В конфиге их нет** — второй список разошёлся бы с заголовками молча. ### Вызов из другого плагина diff --git a/av-dev-pm/skills/tasks/references/adopt.md b/av-dev-pm/skills/tasks/references/adopt.md index 20889ab..39a85ed 100644 --- a/av-dev-pm/skills/tasks/references/adopt.md +++ b/av-dev-pm/skills/tasks/references/adopt.md @@ -63,7 +63,7 @@ python3 $tk adopt apply --plan tasks-adopt-plan.json \ 1. **Осмотрись.** Где лежат задачи, роадмап, заметки. Каталог задач по канону — всегда `docs/tasks`. Секции беклога (`--sections`) — по умолчанию - `ядро,инфра`; если у проекта деление другое по существу, оно называется + `Ядро,Инфра`; если у проекта деление другое по существу, оно называется здесь, а не подгоняется под умолчание, и становится **заголовками `##` индекса** — их единственным домом. В `docs/.pm.json` секции не пишутся. 2. **`adopt scan`** по всем источникам разом. Один прогон, одна карта: два diff --git a/av-dev-pm/skills/tasks/references/from-review.md b/av-dev-pm/skills/tasks/references/from-review.md index b03fe69..128f396 100644 --- a/av-dev-pm/skills/tasks/references/from-review.md +++ b/av-dev-pm/skills/tasks/references/from-review.md @@ -49,7 +49,7 @@ Цель обязательна у находки, которая оказалась **новой возможностью** (`kind:feature`): нашлось поведение, которого никто не заказывал, и его надо либо заказать целью, либо убрать. Подходящей цели нет — заведи её - (`add --type goal --section направления`) в том же проходе. + (`add --type goal --section Направления`) в том же проходе. 5. **Покажи карту до создания файлов.** Кластер → задача / идея / строка в пакетный файл / уже заведено / отброшено, и под какую цель — пачкой через `AskUserQuestion`. Это тот же барьер, что и «три кандидата» в интейке из diff --git a/av-dev-pm/skills/tasks/references/task-format.md b/av-dev-pm/skills/tasks/references/task-format.md index b88c187..b119be5 100644 --- a/av-dev-pm/skills/tasks/references/task-format.md +++ b/av-dev-pm/skills/tasks/references/task-format.md @@ -11,7 +11,7 @@ ```markdown # Тай-брейк при равной полноте -- **Секция:** ядро — вышла из спринта: остаток писал нерешённое в журнал +- **Секция:** Ядро — вышла из спринта: остаток писал нерешённое в журнал - **Зачем:** порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт - **Теги:** goal:merge-robustness, kind:fix, sprint:2026-08-03 @@ -40,6 +40,12 @@ префиксом `[goal]` / `[idea]`; обычная задача — без префикса. Отдельного поля типа **нет**: два места для одного факта разъезжаются, а префикс виден прямо в индексе, где и принимается решение «брать или не брать». +- **Форма заголовка — по типу записи.** Задача отвечает на «что нужно сделать» + и пишется глаголом в неопределённой форме («Печатать поле одним куском кода», + «Не отбрасывать молча лишние символы»); цель — на «что приложение будет + уметь»; идея просто называет, о чём она. Почему так — SKILL.md, «Как написана + задача». `check` считает заголовки не в форме действия и печатает число в + здоровье; годность формулировки смотрит агент `task-wording`. - **Мета-блок** — список сразу после заголовка, **поле на строку**. Обязательна секция, причина после тире желательна (именно она объясняет, почему задача здесь оказалась — в том числе «вышла из спринта: …»), «зачем» и теги @@ -159,7 +165,7 @@ ```markdown # [goal] Исход слияния не зависит от порядка доставки -- **Секция:** направления +- **Секция:** Направления - **Теги:** decomposed Ради чего: точки из разных доставок сходятся в один часовой объект, и сегодня @@ -187,7 +193,7 @@ которой задачи есть, а цель с тегом и без задач — прямое приглашение закрыть. - Цель живёт в `ROADMAP.md` и **никогда** — в `BACKLOG.md` или `SPRINT.md`. - **Достигнутая цель не исчезает.** `close <слаг> --implemented` удаляет файл и - переносит строку в секцию `готово` с датой: + переносит строку в секцию `Готово` с датой: `- 2026-08-04 \`merge-order\` — Исход слияния не зависит от порядка доставки. …` Ссылки на файл в ней нет — файл удалён, а битая ссылка это ошибка `check`. Поведение живёт в спеках проекта; роадмап отвечает, **когда и в каком порядке** @@ -219,8 +225,8 @@ | Файл | Что отвечает | Секции | | --- | --- | --- | -| `ROADMAP.md` | что приложение уже умеет и чего ещё не умеет | канонические: `готово`, `запланировано`, `направления`, `разработка` (англ. `done`, `planned`, `directions`, `tooling`) | -| `BACKLOG.md` | что **можно взять** — только задачи | секции проекта (по умолчанию ядро/инфра) | +| `ROADMAP.md` | что приложение уже умеет и чего ещё не умеет | канонические: `Готово`, `Запланировано`, `Направления`, `Разработка` (англ. `Done`, `Planned`, `Directions`, `Tooling`) | +| `BACKLOG.md` | что **можно взять** — только задачи | секции проекта (по умолчанию Ядро/Инфра) | | `SPRINT.md` | какая цель и какой набор под неё | одна: «Набор» | | `REJECTED.md` | что ушло без реализации и почему | — | @@ -239,14 +245,20 @@ Постоянно пустая секция со старой семантикой «разбираются пачками» противоречила бы правилу «эскалируем немедленно», поэтому `init` её не заводит, а `check` говорит о ней в чужом беклоге. Переезжаешь с такой секцией — удали её. В секции -**`запланировано`** очередь значима и обосновывается прозой; двигают строку -`move --section запланировано --after <другой>`. В секции **`готово`** +**`Запланировано`** очередь значима и обосновывается прозой; двигают строку +`move --section Запланировано --after <другой>`. В секции **`Готово`** строки не той формы, что у прочих индексов: дата, слаг, заголовок — как в `REJECTED.md`, и по той же причине (файла уже нет, ссылаться некуда). **Секции роадмапа закреплены** — состав, полнота и единство языка проверяются `check`; секции беклога проект называет сам. Почему так — SKILL.md. +**Заголовок секции пишется с прописной, и после него идёт пустая строка** — во +всех индексах, включая секции беклога, имена которых выбирает проект. Написание +канонических секций и отбивку правит `check --fix`; он же сводит написание +секции в мете файла с заголовком индекса — **имя секции принадлежит заголовку**, +файл на неё лишь ссылается, и принадлежность сверяется по нижнему регистру. + Индексы **производны**: расходятся с файлом — правим индексы (`check --fix`). Строку руками не пишут. @@ -269,7 +281,7 @@ ```markdown - 2026-07-23 `versii-kachestvo-repaki` — Версии и качество одного тайтла. Причина: калибровка болей — не боль, ни разу не возникло за полгода. - Была секция: инфра. + Была секция: Инфра. ``` Реализованные сюда не попадают: у них остаётся коммит и документация. У diff --git a/av-dev-pm/skills/tasks/scripts/tasks.py b/av-dev-pm/skills/tasks/scripts/tasks.py index a324c5a..c6b72d1 100755 --- a/av-dev-pm/skills/tasks/scripts/tasks.py +++ b/av-dev-pm/skills/tasks/scripts/tasks.py @@ -127,7 +127,7 @@ DEFAULTS = { # с диском первым делом, иначе кривой ключ выглядит как пропавший файл). PATH_KEYS = ("items", "backlog", "roadmap", "sprint", "rejected") -DEFAULT_SECTIONS = "ядро,инфра" +DEFAULT_SECTIONS = "Ядро,Инфра" # Секции роадмапа **канонические**, в отличие от секций беклога. Причина не в # любви к единообразию: у каждой своя семантика — достигнутое, очередь, долгие @@ -136,12 +136,14 @@ DEFAULT_SECTIONS = "ядро,инфра" # не несут, это полки, и остаются делом проекта. # # Пара на секцию: русское имя и английское. Проект держит **один язык на весь -# индекс** — вперемешку это дрейф, который check называет вслух. +# индекс** — вперемешку это дрейф, который check называет вслух. Сверка везде +# идёт по нижнему регистру, а пишется — как здесь: заголовок предложением, с +# прописной. ROADMAP_SECTIONS = ( - ("готово", "done"), # достигнутое: что приложение уже умеет - ("запланировано", "planned"), # очередь значима, обоснована прозой - ("направления", "directions"), # очереди нет, тянутся долго - ("разработка", "tooling"), # инструмент и процесс, не про приложение + ("Готово", "Done"), # достигнутое: что приложение уже умеет + ("Запланировано", "Planned"), # очередь значима, обоснована прозой + ("Направления", "Directions"), # очереди нет, тянутся долго + ("Разработка", "Tooling"), # инструмент и процесс, не про приложение ) ACHIEVED, PLANNED = 0, 1 # индексы в ROADMAP_SECTIONS DEFAULT_ROADMAP_SECTIONS = ",".join(ru for ru, _ in ROADMAP_SECTIONS) @@ -157,6 +159,12 @@ META_FIELD = re.compile(r"^\*\*(.+?):\*\*\s*(.*)$") # сами; пишется всегда новое. WHY_KEYS = ("зачем", "why", "хук", "hook") +# Ключи, которые скрипт у себя признаёт. Нужны не разбору (там ключи +# перечислены по месту), а поиску поля, отбившегося от блока: сверять с +# закрытым списком — единственный способ не спутать поле меты со строкой тела +# вида `- **Важно:** …`. +META_KEYS = {"секция", "section", "теги", "tags", *WHY_KEYS} + def meta_span(lines: list[str]) -> tuple[int, int] | None: """Границы мета-блока — `[начало, конец)`. None, если меты нет. @@ -339,7 +347,7 @@ class Plan: self.writes.append((path, text)) def index(self, lay: "Layout", kind: str, lines: list[str]) -> None: - self.file(lay.index(kind), "\n".join(lines)) + self.file(lay.index(kind), "\n".join(spaced_sections(lines))) def delete(self, path: Path) -> None: self.deletes.append(path) @@ -538,6 +546,42 @@ def parse_entries(lines: list[str]) -> tuple[dict[str, dict], list[str]]: return entries, sections +INFINITIVE = re.compile(r"(?:ть|ти|чь)(?:ся)?$") + + +def action_title(title: str) -> bool: + """Заголовок задачи в форме действия: первое слово — глагол в неопределённой + форме, перед ним допускается «не». + + Эвристика, и намеренно грубая: русская морфология без словаря не разбирается, + а «Часть данных теряется» от «Печатать поле» отличается ровно окончанием + первого слова. Поэтому результат идёт **счётчиком в здоровье**, а не + замечанием: ошибиться на одном заголовке дешевле, чем не заметить двадцати. + """ + words = re.findall(r"[^\W\d_]+", title) + if not words: + return False + first = words[0].lower() + if first in ("не", "не-") and len(words) > 1: + first = words[1].lower() + return bool(INFINITIVE.search(first)) + + +def spaced_sections(lines: list[str]) -> list[str]: + """Отбивка после заголовка секции. Заголовок, пустая строка, потом + содержимое — во всех индексах одинаково. + + Живёт на записи, а не на вставке: через `Plan.index` проходит **каждая** + запись индекса, и чинить отбивку в каждом месте вставки значило бы + полагаться на то, что ни одно из них не забыли.""" + out: list[str] = [] + for i, line in enumerate(lines): + out.append(line) + if SECTION.match(line) and i + 1 < len(lines) and lines[i + 1].strip(): + out.append("") + return out + + def index_lint(lines: list[str], label: str) -> list[str]: """Структурные дефекты индекса, которых схлопнутый dict не видит: битые строки-пункты, дубли на один файл, задачи до первой секции.""" @@ -547,6 +591,9 @@ def index_lint(lines: list[str], label: str) -> list[str]: for num, line in enumerate(lines, 1): if (m := SECTION.match(line)): section = m.group(1) + if num < len(lines) and lines[num].strip(): + errors.append(f"{label}:{num}: после заголовка «{section}» нет" + f" пустой строки; починит `check --fix`") continue if not line.startswith("- ["): continue @@ -597,14 +644,15 @@ def parse_task(path: Path) -> dict: rtype, bare = m.group(1).strip().lower(), m.group(2).strip() # Мета — блок под заголовком (task-format.md). Порядок полей свободный: # секция распознаётся, где бы она ни стояла. - section, reason, why, tags, legacy = "", "", "", [], False + section, section_raw, 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() + section = section.rstrip(".,") + section_raw, section = section, section.lower() elif key in WHY_KEYS: why = value elif key in ("теги", "tags"): @@ -612,11 +660,27 @@ def parse_task(path: Path) -> dict: goal = next((t[len(GOAL_TAG):] for t in tags if t.startswith(GOAL_TAG)), "") kind = next((t[len(KIND_TAG):] for t in tags if t.startswith(KIND_TAG)), "") return {"title": title, "bare": bare, "type": rtype, "section": section, + "section_raw": section_raw, "reason": reason, "why": why, "tags": tags, "goal": goal, "kind": kind, - "path": path, + "path": path, "stray_meta": stray_meta(lines, span), "legacy_meta": legacy, "text": text, "body": body_sections(text)} +def stray_meta(lines: list[str], span: tuple[int, int] | None) -> list[str]: + """Поля меты, оставшиеся за пределами блока. Так выглядит мета, разорванная + пустой строкой: разбор дочитывает блок до разрыва, а всё, что ниже, + становится телом — и поля теряются молча. Ловить обязательно: молчаливая + потеря «зачем» или рода работы неотличима от того, что их не задавали, а + следующий `edit` допишет второе такое же поле в мету.""" + if span is None: + return [] + out = [] + for j in range(span[1], len(lines)): + if (m := META_ITEM.match(lines[j].strip())) and m.group(1).strip().lower() in META_KEYS: + out.append(m.group(1).strip()) + return out + + def tasks_of(lay: Layout) -> dict[str, dict]: if not lay.items.is_dir(): return {} @@ -836,6 +900,12 @@ def check(lay: Layout, fix: bool = False) -> int: if task["legacy_meta"]: errors.append(f"{name}: мета одной строкой — старая форма;" f" `check --fix` перепишет её списком") + if task["stray_meta"]: + errors.append(f"{name}: поле меты в теле" + f" ({', '.join(task['stray_meta'])}) — мета разорвана" + f" пустой строкой, и всё, что ниже разрыва, потеряно." + f" Убери пустую строку внутри блока; `--fix` этого не" + f" делает: какое из двух значений верное, знает человек") if not task["section"]: errors.append(f"{name}: нет поля **Секция:** в мета-блоке") elif task["section"] not in known[home]: @@ -1018,6 +1088,17 @@ def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None: print(f" с открытым вопросом: {len(questions)}" f" — в спринт не берутся, разбор первым шагом сессии") + # Форма заголовка — счётчиком, а не замечанием на файл. Правило верное, но + # проверка эвристическая, а беклог, заведённый до правила, переоформляют не + # «заодно»: десятки одинаковых замечаний научили бы пропускать весь блок. + flat = sorted(n[:-3] for n, t in tasks.items() + if t["type"] in TAKEABLE and not action_title(t["bare"])) + if flat: + print(f" заголовков не в форме действия: {len(flat)}" + f" ({', '.join(flat[:5])}{', …' if len(flat) > 5 else ''})" + f" — задача отвечает на «что нужно сделать»:" + f" «Печатать поле одним куском», а не «Поле печатается одним куском»") + goals = {n[:-3]: t for n, t in tasks.items() if t["type"] == GOAL} if goals: counts = {g: sum(1 for t in tasks.values() if t["goal"] == g) for g in goals} @@ -1152,7 +1233,11 @@ def roadmap_lint(lines: list[str], label: str) -> list[str]: f" закреплены; прозаический заголовок здесь — секция," f" в которую может уехать цель") continue - langs.add(0 if section.lower() == ROADMAP_SECTIONS[i][0] else 1) + langs.add(0 if section.lower() == ROADMAP_SECTIONS[i][0].lower() else 1) + if section not in ROADMAP_SECTIONS[i]: + errors.append(f"{label}: секция «{section}» написана не как в каноне" + f" ({' | '.join(ROADMAP_SECTIONS[i])}) — заголовок" + f" пишется с прописной; починит `check --fix`") if i in seen: errors.append(f"{label}: секция «{section}» повторяет «{seen[i]}» —" f" это одна и та же секция на двух языках") @@ -1207,9 +1292,10 @@ def insert_entry(lines: list[str], section: str, entry: str, raise Usage(f"секции «{section}» в индексе нет") end = next((j for j in range(hi + 1, len(lines)) if SECTION.match(lines[j])), len(lines)) if first: - ins = hi + 1 - while ins < end and not lines[ins].strip(): - ins += 1 + # Пустые строки после заголовка пропускаются, но только если за ними + # что-то есть: у пустой секции пропускать нечего, и строка, вставленная + # в её конец, съела бы отбивку перед следующим заголовком. + ins = next((j for j in range(hi + 1, end) if lines[j].strip()), hi + 1) elif after: ai = find_entry_index(lines[hi:end], after) if ai is None: @@ -2096,6 +2182,50 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: files[task["path"]] = upd fixed.append(f"{name}: проставлен тег «{DECOMPOSED_TAG}» — у цели есть задачи") + # 5. Форма индексов: канонический регистр секций роадмапа и отбивка после + # заголовков. Регистр правится только у **канонических** секций: имена + # секций беклога — дело проекта, и подгонять их под свой вкус скрипт + # права не имеет. + canon = {n.lower(): pair for pair in ROADMAP_SECTIONS for n in pair} + for kind, lines in idx.items(): + if kind == "roadmap": + for j, line in enumerate(lines): + if not (m := SECTION.match(line)): + continue + pair = canon.get(m.group(1).lower()) + if pair is None or m.group(1) in pair: + continue + want = pair[0] if m.group(1).lower() == pair[0].lower() else pair[1] + lines[j] = f"## {want}" + fixed.append(f"{lay.name(kind)}: секция «{m.group(1)}» → «{want}»") + dirty.add(kind) + if spaced_sections(lines) != lines: + fixed.append(f"{lay.name(kind)}: отбивка после заголовков секций") + dirty.add(kind) + + # 6. Написание секции в мете. Имя секции принадлежит **заголовку индекса** — + # файл на секцию только ссылается, а принадлежность сверяется по нижнему + # регистру. Поэтому расхождение в одном регистре однозначно: побеждает + # заголовок. Без этого шага переезд на канон оставил бы «Готово» в + # роадмапе и «готово» в каждом файле цели. + for name, task in tasks.items(): + kind = home_index(task) + if not task["section_raw"] or kind not in idx: + continue + _, heading = find_section(idx[kind], task["section"]) + if not heading or heading == task["section_raw"]: + continue + # Правим уже отложенный текст, если файл трогали выше: перечитать его с + # диска значило бы стереть проставленный шагом 4 тег. + staged = files.get(task["path"]) + src = (staged.splitlines() if staged is not None + else task["path"].read_text(encoding="utf-8").splitlines()) + rebuilt = meta_rebuilt(src, section=heading) + if rebuilt is None: + continue + files[task["path"]] = "\n".join(rebuilt) + "\n" + fixed.append(f"{name}: секция в мете «{task['section_raw']}» → «{heading}»") + plan = Plan() for path, text in files.items(): plan.file(path, text) @@ -2145,14 +2275,14 @@ def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str], f"- **{ROADMAP_SECTIONS[ACHIEVED][0]}** — достигнутое: строку пишет\n" " `tasks.py close <цель> --implemented`, ссылки на файл в ней нет —\n" " файл удаляется, поведение живёт в спеках;\n" - "- **запланировано** — очередь значима и обосновывается прозой;\n" - "- **направления** — очереди нет, тянутся долго;\n" - "- **разработка** — инструмент и процесс, не возможности приложения.\n" - " Отдельно, чтобы не читаться как обещание продукта.\n\n" + f"- **{ROADMAP_SECTIONS[PLANNED][0]}** — очередь значима и обосновывается прозой;\n" + f"- **{ROADMAP_SECTIONS[2][0]}** — очереди нет, тянутся долго;\n" + f"- **{ROADMAP_SECTIONS[3][0]}** — инструмент и процесс, не возможности\n" + " приложения. Отдельно, чтобы не читаться как обещание продукта.\n\n" "Секции **канонические** и переименованию проектом не подлежат:\n" "у каждой свой смысл, и в первую пишет сам `close`. Английский\n" - "вариант — done | planned | directions | tooling, один язык на весь\n" - "индекс.\n\n" + f"вариант — {' | '.join(pair[1] for pair in ROADMAP_SECTIONS)}," + " один язык на весь\nиндекс.\n\n" + "".join(f"## {s}\n\n" for s in roadmap_sections)) out[lay.index("sprint")] = empty_sprint(lay) out[lay.index("rejected")] = (