diff --git a/DECISIONS.md b/DECISIONS.md index ccf2672..d8d7faf 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1834,3 +1834,84 @@ ADR, запискам разведки и сообщениям коммитов 100. **Версия канона отделяет состояния проектов, а не редакции текста** — и ровно поэтому её нельзя не поднять, когда состояние хоть одного проекта уже зафиксировано. + +## 27. Тип записи стал единственной осью и задаёт схему (2026-08-05) + +Заметка просила «каждый тип задач сделать своей сущностью»: эмодзи на тип, тип +первым полем меты, категория вместо секции, описание типа с обязательными +разделами и алгоритмом, идеи в конец. Разбор показал, что первый шаг обязан быть +другим — не добавить типу свойств, а **сократить число осей**. + +**ААББ. Осей было две, и ортогональность была фальшивой.** Тип записи +(`goal`/`idea`/`task`) и род работы (`kind:<род>` тегом) давали двенадцать клеток +произведения, из которых законны шесть: у цели род запрещён, у задачи обязателен, +у идеи пуст и на практике не ставится. Плюс «алгоритм работы над записью такого +типа» крепится не к `task`, а к `fix` и `research` — то есть к роду. Ось, к +которой пишется алгоритм, и была настоящим типом. Оси схлопнуты в одну из пяти +значений: `goal` | `feature` | `fix` | `chore` | `research`. + +**ВВГГ. Тип `idea` упразднён: состояние не может быть типом.** Он значил не род +работы, а незаполненность — «первый, второй или третий вопрос теста готовности не +отвечается». Состояние меняется по мере того, как запись дописывают, а тип меняют +командой, и на этом расхождении `idea` и жила: её приходилось «понижать» и +«повышать» вручную. Теперь состояние выводится из заполненности — **`research` +без раздела «Вопрос» это сырьё**, — и различие держит та же машина, что и всё +остальное. + +Цена решения названа сразу: `research` теперь вбирает и замер реальности, и +сырую функцию («Подсказка следующего хода»). Обосновано это тем, что у обоих +**один исход — записанный ответ, а не изменение системы**, и одна приёмка. Имя +`rnd` из заметки отклонено в пользу `research`: аббревиатура читается как +`random` и не расшифровывается тому, кто вернётся к беклогу через квартал, а +`research` уже стоял в файлах живых проектов — миграция тронула только бывшие +идеи. + +**ДДЕЕ. Дом типа — поле меты, эмодзи производна.** Прежнее правило «отдельного +поля типа нет: два места для одного факта разъезжаются» отменено не потому, что +разонравилось, а потому, что его аргумент был против **префикса плюс поля**. При +переносе дома в мету дом остаётся один; из индекса тип при этом пропадал бы — там, +где принимают решение «брать или не брать», — и это чинит эмодзи. Она стоит в H1, +а не в строке индекса, чтобы инвариант «заголовок в индексе дословно» остался +нетронутым: одна проверка вместо двух. + +**ЖЖЗЗ. Поле места назвали по типу, а не одним словом на всех.** «Категория» +вместо «Секции» — просьба заметки, но одинаковое переименование закрепило бы +конфляцию: у задачи поле называет полку домена, в которую она вернётся из +спринта, у цели — часть роадмапа, то есть состояние очереди. Разные имена +(`Категория` / `Секция`) выбраны именно потому, что **какое поле обязательно, +решает тип** — то самое, ради чего затевалась вся правка. + +**ИИКК. Два новых обязательных раздела появились из уже записанных правил, +которые нечем было проверить.** «Не воспроизводится — это `research`, а не `fix`» +стояло в каноне и не проверялось: раздел `Воспроизведение` делает его +проверяемым. Приёмка разведки — «записанный ответ, а не изменённый код» — тоже +стояла, но `sprint take` требовал от `research` два-пять критериев с оракулами, +и они писались ради проверки; вместо них `Вопрос` и `Куда ляжет ответ`. + +**ЛЛММ. Сортировка «по важности» отклонена, «сырьё в конец» взято.** Первая +требует, чтобы кто-то важность поддерживал, — это ровно тот приоритет, от +которого правило 4 отказалось сознательно. Вторая **выводится из типа и +заполненности**, а не назначается человеком, и потому проверяется машиной и +приоритетом не становится. Разрез прошёл по признаку «кто источник порядка», а не +по признаку «полезно ли». + +### Что из этого следует + +101. **Правило можно отменять его собственным аргументом.** «Отдельного поля типа + нет» держалось на «два места для одного факта»; перенос дома оставил одно + место, и правило перестало применяться. Проверять надо не запись правила, а + то, выполняется ли ещё его посылка. +102. **Схема, шаблон и проверка растут из одной таблицы.** `TYPE_SCHEMA` кормит и + `body_template`, и `schema_verdict`: иначе `add` кладёт то, на чём + `sprint take` потом откажет. Тот же приём, что нормализатор `spaced_sections` + для оформления индексов. +103. **`--fix` не угадывает того, чего нет.** Тип переносится из тега `kind:` и + префикса `[goal]`/`[idea]` детерминированно, но записи, заведённые до + появления рода работы, не несут ни того ни другого — `feature` от `chore` + машина не отличает. Они уходят в `НЕОДНОЗНАЧНО` поимённо, а не получают + значение по умолчанию, которое врало бы ровно там, где по нему принимают + решение. +104. **Мигрирующие шаги обязаны читать отложенный текст, а не диск.** Шагов, + правящих мету, стало пять, и второй, перечитавший файл, стёр бы правку + первого. Общий `stage()` поверх `files` снял целый класс отказов, который до + этого держался на том, что шагов было мало. diff --git a/TODO.md b/TODO.md index 0751f6f..23c1d41 100644 --- a/TODO.md +++ b/TODO.md @@ -191,3 +191,12 @@ jellybit 43. Шаги повышения — [changelog.md](av-dev-pm/skills/can `"canon": 4` - [ ] jellybit едет сразу на 4: `Готово` заводить **последней**, секцию сопровождения — сразу с новым именем, переставлять дважды не нужно +- [ ] типы: `check --fix` переведёт `kind:`/`[goal]`/`[idea]` в поле «Тип», снимет + тег, поставит эмодзи, переименует «Секция» → «Категория» у задач и снесёт + сырьё в конец категорий — **за один проход, вместе с порядком секций** +- [ ] разобрать `НЕОДНОЗНАЧНО` после `--fix`: записи без типа (заведены до + появления рода работы) машина не угадывает — `edit <слаг> --type …` +- [ ] новые обязательные разделы — **не задним числом**: `Воспроизведение` у + каждого `fix` и `Вопрос` + `Куда ляжет ответ` у каждого `research` пишутся + по мере того, как задача идёт в набор (`sprint take` без них откажет). + Сколько записей готово к взятию, печатает блок здоровья `check` diff --git a/av-dev-pm/agents/task-form.md b/av-dev-pm/agents/task-form.md index 86875e5..7da4826 100644 --- a/av-dev-pm/agents/task-form.md +++ b/av-dev-pm/agents/task-form.md @@ -1,6 +1,6 @@ --- name: task-form -description: "Проверка формы записи каталога задач по существу: форма заголовка по типу (цель — что приложение будет уметь, задача — что нужно сделать, идея — о чём она), «зачем», пересказывающее заголовок вместо состояния и боли, раздел «Затрагивает» с замыслом вместо границ, критерий приёмки с оракулом только на словах, предписание процесса в теле, и связь задачи со строкой «Завершения» её цели. Читает файл цели, на которую ссылается задача. Отдаёт готовые формулировки на замену и ничего не правит сам. Язык текста (залог, оценки, стоп-слова, англицизмы) смотрит отдельный агент doc-wording. Использовать после заведения или разбора пачки записей, до взятия в спринт и на переоценке. Только чтение." +description: "Проверка формы записи каталога задач по существу: тип, разошедшийся с содержанием записи, форма заголовка по типу (цель — что приложение будет уметь, задача — что нужно сделать, разведка — о чём она), «зачем», пересказывающее заголовок вместо состояния и боли, раздел «Затрагивает» с замыслом вместо границ, критерий приёмки с оракулом только на словах, предписание процесса в теле, и связь задачи со строкой «Завершения» её цели. Читает файл цели, на которую ссылается задача. Отдаёт готовые формулировки на замену и ничего не правит сам. Язык текста (залог, оценки, стоп-слова, англицизмы) смотрит отдельный агент doc-wording. Использовать после заведения или разбора пачки записей, до взятия в спринт и на переоценке. Только чтение." tools: Read, Grep, Glob model: opus color: yellow @@ -29,7 +29,7 @@ color: yellow Список файлов записей (`docs/tasks/items/.md`) или каталог задач целиком. Каталог тебе нужен и сам по себе: задача несёт тег `goal:<слаг>`, и **файл цели -ты открываешь**, иначе шестое правило не проверить. +ты открываешь**, иначе седьмое правило не проверить. Документы проекта — паспорт, архитектура, конвенции — если зовущий их назвал. По ним видно, названа ли граница именем, которое в проекте существует. @@ -38,11 +38,14 @@ color: yellow 1. **Заголовок отвечает на вопрос своего типа.** + Тип стоит первым полем меты — `- **Тип:** …`, — а в заголовке ему + соответствует эмодзи. + | Тип | Отвечает на | Форма | | --- | --- | --- | - | `[goal]` | что приложение будет уметь | утверждение о возможности: «Соперником может быть компьютер» | - | задача | что нужно сделать | глагол в неопределённой форме, допускается «не» перед ним: «Печатать поле одним куском кода» | - | `[idea]` | о чём она | назывное, без обещания: «Подсказка следующего хода» | + | 🎯 `goal` | что приложение будет уметь | утверждение о возможности: «Соперником может быть компьютер» | + | ✨ `feature`, 🐞 `fix`, 🧹 `chore` | что нужно сделать | глагол в неопределённой форме, допускается «не» перед ним: «Печатать поле одним куском кода» | + | 🔬 `research` | о чём разведка | назывное, без обещания: «Подсказка следующего хода» | Описательный заголовок задачи («Лишние символы молча отбрасываются») называет **состояние** и одинаково читается как жалоба и как задание. Заголовок цели в @@ -55,12 +58,32 @@ color: yellow законная возможность**: «исход слияния не зависит от порядка доставки» — цель, а не абстракция. -2. **«Зачем» отвечает на «зачем нужна эта задача»** — состояние, остаток, боль, +2. **Тип сходится с тем, что в записи написано.** Тип — первое поле меты, и он + решает, каких разделов запись требует; разошедшийся тип врёт ровно там, где + по нему принимают решение. Проверяемые расхождения: + + - **`fix`, у которого нечего воспроизвести**, — расхождение приняли на слово. + Либо это `research` («при каких условиях проявляется»), либо `feature`: + поведение никогда и не было заявлено, и чинить нечего; + - **`feature`, после которой снаружи ничего не меняется**, — это `chore`, и + сказать это честно дешевле, чем выдумывать пользовательскую пользу; + - **`chore`, меняющий наблюдаемое поведение**, — это `feature` или `fix`, и у + них другие требования (цель, воспроизведение); + - **`research`, у которого «Вопрос» — это тема, а не вопрос.** «Разобраться с + выводом в терминалах» вопросом не является: на него нельзя ответить. Пока + вопроса нет, запись остаётся сырьём — и это законное состояние, но назови + его. + + Раздел не из схемы своего типа (`Воспроизведение` у `chore`, критерии у + `research`) — сигнал того же расхождения, и `check` о нём говорит замечанием. + Твоя работа — сказать, **какой тип верен**, а не только что текущий не сходится. + +3. **«Зачем» отвечает на «зачем нужна эта задача»** — состояние, остаток, боль, — а не пересказывает заголовок. «Починить разбор хода» при заголовке «Не отбрасывать молча лишние символы» — пересказ: читающий узнаёт то же самое дважды и по-прежнему не знает, почему это лежит в беклоге. -3. **«Затрагивает» перечисляет границы, а не замысел.** Граница — то, у чего есть +4. **«Затрагивает» перечисляет границы, а не замысел.** Граница — то, у чего есть внешняя сторона: команда и её аргументы, эндпоинт, таблица и миграция, формат на диске, публичный тип пакета, внешний сервис. «Переписать хранилище на новый драйвер» — замысел; проверяется вопросом «это можно назвать до того, как @@ -72,17 +95,17 @@ color: yellow становится двумя» вместо «выбор источника хода в модуле партии») — это уже решение о том, как делать. -4. **У критерия назван оракул, и оракул проверяем.** «Оракул: глазами» на +5. **У критерия назван оракул, и оракул проверяем.** «Оракул: глазами» на утверждение, которого глазами не проверить («компьютер не проигрывает ни в одной партии»), — находка: слово стоит, проверки нет. Число критериев считает `tasks.py check`, тебе оно неинтересно. -5. **Предписания процесса в теле нет.** «Делать профилем standard», «взять +6. **Предписания процесса в теле нет.** «Делать профилем standard», «взять такой-то агент» — это выбор, который делают, увидев изменение, а не при постановке. Он же путь понизить требования решением, принятым до проектирования. -6. **Задача называет, какую строку «Завершения» своей цели она двигает.** +7. **Задача называет, какую строку «Завершения» своей цели она двигает.** Открой файл цели из тега `goal:<слаг>` и сверь. Три исхода, и все три — разные находки: @@ -94,8 +117,8 @@ color: yellow это незакрытая часть возможности. Скажи о ней отдельно, вне списка находок по файлам: это про набор, а не про запись. - У задачи **без цели** (`kind:fix`, `chore`, `research`) правило не - применяется вовсе — они служат работоспособности, а не направлению. + У задачи **без цели** (`fix`, `chore`, `research`) правило не применяется + вовсе — они служат работоспособности, а не направлению. ## Чего ты не проверяешь @@ -113,7 +136,7 @@ color: yellow одного правила. **Содержание работы**: нужна ли задача, верно ли выбрана цель, не крупна ли она, -достаточна ли декомпозиция. Шестое правило подходит к этому близко и +достаточна ли декомпозиция. Седьмое правило подходит к этому близко и останавливается там, где кончается сверка с текстом цели. Об этом молчи. ## Порог вмешательства diff --git a/av-dev-pm/skills/canon/references/canon.md b/av-dev-pm/skills/canon/references/canon.md index af9a5d5..685ab8d 100644 --- a/av-dev-pm/skills/canon/references/canon.md +++ b/av-dev-pm/skills/canon/references/canon.md @@ -240,18 +240,30 @@ kebab-case. становится: нормативное поведение живёт в `openspec/specs/`, роадмап отвечает, **когда и в каком порядке** оно появилось. -Плюс два требования к записи задачи, потому что от них зависит, можно ли её -оценить: +**У каждой записи есть тип, и тип решает, что с ней можно делать.** Дом типа — +поле меты `Тип` первой строкой; эмодзи в заголовке от него производна. Словарь +закрыт: -- **род работы** тегом `kind:<род>` из закрытого словаря `feature` | `fix` | - `chore` | `research` — у задачи обязателен, у цели запрещён. Он же решает, - нужна ли цель: у `feature` обязательна, у остальных нет; -- **раздел «Затрагивает»** в теле задачи — границы, которых изменение касается - (эндпоинт, таблица и миграция, формат на диске, публичный тип пакета). +| Тип | Что это | Обязательные разделы | Цель | +| --- | --- | --- | --- | +| 🎯 `goal` | возможность приложения | `Завершение` | — | +| ✨ `feature` | снаружи появляется то, чего не было | `Затрагивает`, `Критерии приёмки` | обязательна | +| 🐞 `fix` | поведение расходится с заявленным | `Воспроизведение`, `Затрагивает`, `Критерии приёмки` | нет | +| 🧹 `chore` | обслуживание, поведение не меняется | `Затрагивает`, `Критерии приёмки` | нет | +| 🔬 `research` | исход — знание, а не изменение | `Вопрос`, `Куда ляжет ответ` | нет | -Оба требуются **к взятию в спринт**, а не к заведению: беклог пополняется чаще, +Схема требуется **к взятию в спринт**, а не к заведению: беклог пополняется чаще, чем разбирается, и требование на входе выгоняло бы в заметки то, что должно -лежать задачей. +лежать задачей. Запись, не собравшая разделы своего типа, — законное состояние +беклога; невзятой её делает `sprint take`. + +Отдельного типа для незаполненной записи нет: «ещё не описано» — состояние, а не +род работы, и называется оно **`research` без раздела «Вопрос»**. Такая запись в +спринт не берётся и лежит в конце своей категории. + +Раскладку, форму записи и алгоритм работы над каждым типом держит скилл `tasks` +(`references/task-<тип>.md`); канон фиксирует только словарь типов и то, от чего +зависит, читается ли проект как продукт. ### `CLAUDE.md` @@ -324,7 +336,7 @@ kebab-case. | --- | --- | | `docs/review-brief.md` | документы канона и есть бриф; остаток — в `review.md` | | `docs/specs/` | `openspec/specs/` (поведение) и `architecture.md` (обзор) | -| `docs/drafts/` | идея → задача `[idea]`; отказ → ADR; порядок → `ROADMAP.md`; размышление → `opsx:explore` | +| `docs/drafts/` | идея → запись `research`; отказ → ADR; порядок → `ROADMAP.md`; размышление → `opsx:explore` | | `docs/plan.md` | `docs/tasks/ROADMAP.md` | | `BRIEF.md` | `passport.md` | | `docs/backlog/` | `docs/tasks/` | @@ -364,10 +376,14 @@ kebab-case. `database.md`. `tasks` — настройки каталога задач, переехавшие сюда из прежнего `/.tasks.json`: **один конфиг на весь канон, а не по одному на каталог**. Внутри `tasks` — **только имена файлов и заголовков** (`items`, `backlog`, -`plan`, `sprint`, `rejected`, `sprint_section`, `questions_heading`, -`criteria_heading`, `oracle_word`), и ключ пишется, лишь когда имя отличается от -умолчания. **Секций беклога здесь нет:** их дом — заголовки `##` самого индекса, -и второй список сразу разошёлся бы с первым. Неизвестный ключ `tasks.py` +`roadmap`, `sprint`, `rejected`, `sprint_section`, `oracle_word` и заголовки +разделов тела: `criteria_heading`, `surface_heading`, `questions_heading`, +`completion_heading`, `repro_heading`, `question_heading`, `answer_heading`, +`scope_heading`), и ключ пишется, лишь когда имя отличается от умолчания. +**Словаря типов здесь нет** — он закрыт каноном, а не настраивается проектом: +настраиваемый словарь типов разъехался бы на синонимах ровно так же, как +открытый. **Категорий беклога здесь тоже нет:** их дом — заголовки `##` самого +индекса, и второй список сразу разошёлся бы с первым. Неизвестный ключ `tasks.py` отвергает кодом 3, поэтому лишнее слово в этом объекте останавливает работу с задачами целиком. diff --git a/av-dev-pm/skills/canon/references/changelog.md b/av-dev-pm/skills/canon/references/changelog.md index fd9cedb..f25d225 100644 --- a/av-dev-pm/skills/canon/references/changelog.md +++ b/av-dev-pm/skills/canon/references/changelog.md @@ -13,17 +13,25 @@ upgrade` идёт по записям снизу вверх от версии п --- -## Версия 4 — 2026-08-04 +## Версия 4 — 2026-08-05 -Одна секция роадмапа переименована, и вместе с именем расширен её смысл; -достигнутое переехало вниз. Плюс общий словарь для трёх мест канона, которые -говорят про одну тему разными словами. Раскладка не меняется, файлов не -прибавляется. +Две правки, обе про то, как читается каталог задач. Первая — секция роадмапа +переименована, и вместе с именем расширен её смысл; достигнутое переехало вниз. +Вторая — **у каждой записи появился тип, и тип определяет, что с записью можно +делать**. Раскладка не меняется, файлов канона не прибавляется. -**Что переехало:** секция роадмапа `Разработка` → **`Сопровождение`** (англ. -`Tooling` → **`Operations`**). Прежнее имя называло слишком много: роадмап -**весь** про разработку, и секция с таким именем не отличалась от остальных -ничем. +**Что переехало:** + +- секция роадмапа `Разработка` → **`Сопровождение`** (англ. `Tooling` → + **`Operations`**). Прежнее имя называло слишком много: роадмап **весь** про + разработку, и секция с таким именем не отличалась от остальных ничем; +- **тип записи** — из префикса заголовка (`[goal]`/`[idea]`) и тега + `kind:<род>` в **поле меты `Тип`** первой строкой. Эмодзи в заголовке от него + производна; +- **поле места** у задачи: `Секция` → **`Категория`**. У цели остаётся `Секция`: + у задачи поле называет полку домена, в которую она вернётся из спринта, у цели + — часть роадмапа, то есть состояние очереди. Одно имя на два смысла было + конфляцией. **Что добавилось:** @@ -51,6 +59,28 @@ upgrade` идёт по записям снизу вверх от версии п 5. **Заголовок секции отбивается пустой строкой с обеих сторон.** Прежде проверялась только строка после заголовка; перестановка секций двигает целые блоки, и два заголовка оказываются вплотную. Правит `check --fix`. +6. **Тип — единственная ось записи, закрытый словарь из пяти значений:** + `goal` | `feature` | `fix` | `chore` | `research`. Осей было две — тип записи + (`goal`/`idea`/`task`) и род работы (`kind:` тегом), — но из двенадцати + клеток произведения законны были шесть, а алгоритм работы крепится к роду, а + не к типу. Оси схлопнуты. +7. **Тип задаёт схему тела:** какие разделы обязательны, какие допустимы, нужна + ли цель, берётся ли запись в спринт. Проверяет `sprint take`, замечания даёт + `check`. Два раздела новые: **`Воспроизведение`** у `fix` (не + воспроизводится — это `research`, а не `fix`; правило было записано и не + проверялось) и **`Вопрос` + `Куда ляжет ответ`** у `research` вместо + критериев приёмки (приёмка разведки — записанный ответ, и критерии в форме + «оракул: тест» ей натянуты). +8. **Тип `idea` упразднён.** Он значил не род работы, а состояние + незаполненности, а состояние типом быть не может. Теперь оно называется + честно: `research` без раздела «Вопрос» — **сырьё**. В спринт не берётся, как + и прежняя идея, лежит **в конце своей категории** (проверяет `check`, + переставляет `--fix`) и отбирается `list --raw`. Порядка «по важности» в + беклоге по-прежнему нет: этот порядок производен от типа, а не назначен + человеком. +9. **Алгоритм работы над каждым типом** — отдельным файлом, + `skills/tasks/references/task-<тип>.md`: схема, что проверяет машина, что + человек, и порядок шагов. **Что сделать проекту:** @@ -63,10 +93,22 @@ upgrade` идёт по записям снизу вверх от версии п docs/tasks` покажет расхождение поимённо. 3. Перечитать состав секции: цели про выкладку, метрики, логи и инфраструктуру, если они лежали в `Направлениях` за неимением места, переезжают сюда. -4. Прогнать `python3 tasks.py check --dir docs/tasks --fix`: он переставит - секции роадмапа в канонический порядок (`Готово` уедет вниз вместе со всем - содержимым) и поправит отбивку заголовков. -5. `docs/.pm.json`: `"canon": 4`. +4. Прогнать `python3 tasks.py check --dir docs/tasks --fix`. За один проход он + переставит секции роадмапа в канонический порядок (`Готово` уедет вниз вместе + со всем содержимым), поправит отбивку заголовков и **переведёт записи на + типы**: перенесёт значение из тега `kind:` и префикса `[goal]`/`[idea]` в поле + `Тип`, снимет тег, поставит эмодзи в заголовок, переименует `Секция` → + `Категория` у задач и снесёт сырьё в конец категорий. +5. Разобрать то, что `--fix` вернул пометкой `НЕОДНОЗНАЧНО`. Главный случай — + **записи без типа**: заведённые до появления рода работы, они не несут ни + тега, ни префикса, и машина их не угадывает (`feature` от `chore` не + отличает). Проставить руками: `edit <слаг> --type …`. +6. Дописать новые обязательные разделы у задач, которые собираются в спринт: + `Воспроизведение` у каждого `fix`, `Вопрос` и `Куда ляжет ответ` у каждого + `research`. Не «заодно по всему беклогу», а порциями переоценки: `check` + ошибкой это не считает, отказывает только `sprint take`. Сколько задач готово + к взятию, печатает блок здоровья `check`. +7. `docs/.pm.json`: `"canon": 4`. ## Версия 3 — 2026-08-04 diff --git a/av-dev-pm/skills/canon/scripts/docs.py b/av-dev-pm/skills/canon/scripts/docs.py index 3671b73..2f96c9b 100644 --- a/av-dev-pm/skills/canon/scripts/docs.py +++ b/av-dev-pm/skills/canon/scripts/docs.py @@ -70,7 +70,7 @@ RETIRED = { "local-research.md": "→ docs/research/", "research.md": "→ docs/research/", "specs": "поведение → openspec/specs/, обзор → docs/architecture.md", - "drafts": "идея → задача [idea], отказ → ADR, порядок → ROADMAP.md", + "drafts": "идея → запись research, отказ → ADR, порядок → ROADMAP.md", "backlog": "→ docs/tasks/", "review": "→ docs/review.md", } diff --git a/av-dev-pm/skills/session/SKILL.md b/av-dev-pm/skills/session/SKILL.md index b5def84..8dfd059 100644 --- a/av-dev-pm/skills/session/SKILL.md +++ b/av-dev-pm/skills/session/SKILL.md @@ -37,8 +37,8 @@ description: "Ритуал между спринтами и ведение са ## Единицы -- **Цель** — то, ради чего набирается спринт. Файл `[goal]`, перечисленный в - `ROADMAP.md`. Цель постоянна: живёт, пока живёт направление. +- **Цель** — то, ради чего набирается спринт. Файл типа `goal` (🎯), + перечисленный в `ROADMAP.md`. Цель постоянна: живёт, пока живёт направление. - **Задача** — то, что мерджится целиком и даёт видимую пользу. - **Вопрос** — решение человека. Не останавливает начатую работу, но **блокирует взятие** задачи в спринт. Живёт внутри файла задачи разделом «Вопросы» и тегом diff --git a/av-dev-pm/skills/session/references/cadence.md b/av-dev-pm/skills/session/references/cadence.md index 4d5921e..3cc1e84 100644 --- a/av-dev-pm/skills/session/references/cadence.md +++ b/av-dev-pm/skills/session/references/cadence.md @@ -105,11 +105,13 @@ 4. **Пере-кластеризуй по общей причине.** Несколько задач, оказавшихся симптомами одного дефекта, сливаются в одну — это находка, которую интейк дать не мог. 5. **Гигиена полей** — протухшее «зачем», вопрос в прозе, снятый ответ, свойство - репозитория в рамках, предписание процесса в теле, род работы, разошедшийся с + репозитория в рамках, предписание процесса в теле, тип, разошедшийся с задачей, границы вместо реализации в разделе «Затрагивает». Список и правила — - в скилле `tasks`. **Переоценка — то самое место, где беклог добирает род - работы и границы:** требовать их на входе значило бы выгонять в заметки то, - что должно лежать задачей, а к взятию в спринт они уже обязательны. + в скилле `tasks`. **Переоценка — то самое место, где беклог добирает тип и + разделы его схемы:** требовать их на входе значило бы выгонять в заметки то, + что должно лежать задачей, а к взятию в спринт они уже обязательны. Сколько + записей готово к взятию, печатает блок здоровья `check`, — по этому числу и + видно, добрала переоценка или нет. Затем — то, что решает пользователь: @@ -122,8 +124,9 @@ заказывал. Операционной задаче (`fix`, `chore`, `research`) цель не нужна, и выдумывать её здесь не надо. 8. **Задача ли это по-прежнему.** Не проходит тест «готова к взятию» → `edit - --type idea`, дальше штурм. Разрослась → это несколько задач под той - же целью, дальше декомпозиция. + --type research` и опустошённый раздел «Вопрос», то есть сырьё; дальше + штурм. Разрослась → это несколько задач под той же целью, дальше + декомпозиция. 9. **Переоценка по измеренному.** Спринт производит числа — сколько на самом деле стоит такая работа, что оказалось дороже ожидания. Эти числа меняют цену **других** задач, и именно здесь это применяется: задача, чья цена выросла @@ -167,7 +170,7 @@ > - Взять в ближайший набор — без бэкапа ретеншн опасен > - Выкинуть > 3. `guessit-sputnik` — вынести распознавание в сервис-спутник -> - Понизить до `[idea]` *(рекомендую)* — не проходит тест «готова к взятию» +> - Понизить до сырья (`--type research`) *(рекомендую)* — не проходит тест «готова к взятию» > - Оставить задачей Каждый вариант несёт причину — ту самую, что уедет в `--reason`. Ответы применяй @@ -184,15 +187,16 @@ 2. **Цель называет человек.** Это продуктовое решение, а не механика: агент предлагает и объясняет, но не выбирает. 3. **Набор собирает агент** — `sprint start --goal <слаг>`, затем `sprint take - …`. Скрипт не даст взять цель, идею, задачу с чужой целью, с открытым - вопросом, без критериев приёмки, без рода работы или без раздела - «Затрагивает». Задача без цели вовсе (`fix`, `chore`, `research`) берётся - свободно — операционная работа входит в набор помимо его цели. + …`. Скрипт не даст взять цель, задачу с чужой целью, с открытым вопросом, без + типа и **без разделов, которых требует её тип** (у `fix` это в том числе + `Воспроизведение`, у `research` — `Вопрос` и `Куда ляжет ответ`, и сырьё + поэтому не берётся вовсе). Задача без цели (`fix`, `chore`, `research`) + берётся свободно — операционная работа входит в набор помимо его цели. 4. **Набор показывается человеку до старта работ.** Показ — это и есть момент заморозки: после него набор не двигается. **В показе называется состав по - роду работы** — три `fix` и ни одной `feature` под целью развития это - разговор про цель, а не про набор, и увидеть его надо до заморозки, а не в - докладе по итогам. + типам** — три `fix` и ни одной `feature` под целью развития это разговор про + цель, а не про набор, и увидеть его надо до заморозки, а не в докладе по + итогам. Здесь же последний дешёвый момент заметить **разнородную задачу**: раздел «Затрагивает» показывает границы до того, как заведено предложение об @@ -200,10 +204,9 @@ перечня, — кандидат на разрез (шов — в `tasks`, `references/split.md`). Замеченная здесь, она стоит одного `edit`; замеченная на ревью — выброшенного предложения. -5. Задача, которой для взятия не хватает только критериев приёмки, границ или - рода, дописывается здесь же — 2–5 утверждений с оракулами, перечень - затрагиваемых границ, `--kind`. Но если для этого нужен ответ человека, это - вопрос, и задача в набор не идёт. +5. Задача, которой для взятия не хватает только разделов её типа, дописывается + здесь же — критерии с оракулами, перечень границ, шаги воспроизведения. Но + если для этого нужен ответ человека, это вопрос, и задача в набор не идёт. **Размер — ориентир, а не закон:** 5–8 задач. Можно взять больше, можно меньше — набор под цель важнее круглого числа; одна крупная задача спринтом тоже бывает. @@ -214,8 +217,8 @@ - Вопросы: разобрано N, из них отвечено без человека N, снято тегов N. - Разбор процесса: что записано и куда. - Изменения списком: удалено как реализованное (со ссылками), ушло без - реализации (с причинами), понижено до идей, слито, сменило цель. -- Новый спринт: цель, набор со слагами, дата, состав по роду работы. + реализации (с причинами), понижено до сырья, слито, сменило тип или цель. +- Новый спринт: цель, набор со слагами, дата, состав по типам. - **Границы покрытия**: сколько задач не трогали и какие именно секции, теги или цели остались — иначе доклад читается как «беклог разобран». - `tasks.py check` после правок — результат строкой. diff --git a/av-dev-pm/skills/tasks/SKILL.md b/av-dev-pm/skills/tasks/SKILL.md index da174f5..f059198 100644 --- a/av-dev-pm/skills/tasks/SKILL.md +++ b/av-dev-pm/skills/tasks/SKILL.md @@ -1,19 +1,19 @@ --- name: tasks -description: Ведение задач и целей как каталога markdown-файлов (одна запись = один файл в items/ + строка в одном из индексов). Заведение задачи, идеи или цели из диалога, разбор находок аудита/ревью, декомпозиция на независимо полезные части, мозговой штурм идеи, гигиена полей и проверка согласованности индексов. Использовать, когда просят добавить задачу/идею/цель, превратить находки ревью в задачи, разбить задачу, проработать идею, поправить формат или проверить беклог. Ритуал между спринтами — скилл session. Не реализует задачи — этим занимается пайплайн проекта. +description: Ведение задач и целей как каталога markdown-файлов (одна запись = один файл в items/ + строка в одном из индексов). У каждой записи есть тип (goal, feature, fix, chore, research), и тип решает, каких разделов она требует и что с ней можно делать. Заведение записи из диалога, разбор находок аудита/ревью, декомпозиция на независимо полезные части, штурм сырья, гигиена полей и проверка согласованности индексов. Использовать, когда просят добавить задачу/идею/цель, превратить находки ревью в задачи, разбить задачу, проработать идею, поправить формат или проверить беклог. Ритуал между спринтами — скилл session. Не реализует задачи — этим занимается пайплайн проекта. --- # Задачи Задачи — каталог markdown-файлов. Одна запись = один файл `items/.md` плюс строка **ровно в одном** индексе. Скилл владеет **форматом и содержимым**: -заводит, редактирует, закрывает, разбирает находки ревью, дробит, штурмует идеи. +заводит, редактирует, закрывает, разбирает находки ревью, дробит, штурмует сырьё. Чем он **не** владеет: ритуалом между спринтами (разбор вопросов → разбор прошедшего спринта → переоценка → выбор цели и набор) — это скилл `session`; и выполнением задачи — это пайплайн проекта. -## Пять правил, из которых всё следует +## Шесть правил, из которых всё следует Ситуация не покрыта инструкцией — решай по ним. @@ -43,10 +43,20 @@ description: Ведение задач и целей как каталога mar 4. **Порядка нет, есть цель — но цель есть не у всякой задачи.** Приоритетов, «повысить» и «встать раньше» нет: «что делать дальше» отвечает набор спринта, а между спринтами порядок не нужен никому. Цель обязательна там, где она и - есть содержание работы, — у **новой возможности** (`kind:feature`). Починка, + есть содержание работы, — у **новой возможности** (`feature`). Починка, техдолг и разведка служат работоспособности, а не направлению, и живут без цели законно; в набор спринта они входят помимо его цели. Придуманная им цель - — то же враньё, от которого спасает род работы. + — то же враньё, от которого спасает тип. + + Единственный порядок, который в беклоге всё-таки есть, **производен от типа**, + а не назначен человеком: **сырьё** (`research` без раздела «Вопрос») стоит в + конце своей категории. Его не берут, и между берущимся оно каждый раз требует + открыть файл, чтобы это понять. Раз порядок выводится, его проверяет машина — + и приоритетом он не становится. +5. **Тип решает, что с записью можно делать.** Тип — единственная ось и первое + поле меты: от него зависят обязательные разделы тела, нужна ли цель, берётся + ли запись в спринт и в каком индексе живёт её строка. Словарь закрыт; ни один + тип не подошёл — значит, в записи их два, и её надо разделить. ## Раскладка @@ -82,10 +92,12 @@ docs/tasks/ открывают чаще всего, — что делается сейчас и что дальше. Порядок проверяет `check`, переставляет `check --fix`. -**Секции роадмапа канонические, секции беклога — нет**, и разница не в любви к +**Секции роадмапа канонические, категории беклога — нет**, и разница не в любви к единообразию. У каждой секции роадмапа свой смысл, в достигнутое пишет сам `close`, и -роадмап, названный по-своему, читался бы только своим автором. Секции беклога -(`Ядро`, `Инфра`) смысла не несут — это полки, и остаются делом проекта. +роадмап, названный по-своему, читался бы только своим автором. Категории беклога +(`Ядро`, `Инфра`) смысла не несут — это полки домена, и остаются делом проекта. +Отсюда и разные имена поля меты: у цели **Секция** (часть роадмапа — состояние +очереди), у задачи **Категория** (полка, в которую она вернётся из спринта). Отсюда четыре правила, которые проверяет `tasks.py check`: **состав закреплён** (чужая секция — ошибка, а не вольность), **все четыре обязаны быть** (нет @@ -93,7 +105,7 @@ docs/tasks/ канонический**. `--roadmap-sections` у `init` нет: выбирать нечего. **Заголовок секции отбит пустой строкой с обеих сторон и написан с прописной.** -Во всех индексах одинаково, включая секции беклога, которые проект называет сам. +Во всех индексах одинаково, включая категории беклога, которые проект называет сам. Написание канонических секций правит `check --fix` (заодно и ссылку на секцию в мете файлов: имя секции принадлежит заголовку индекса, файл на неё только ссылается); отбивку и порядок он правит везде. @@ -143,10 +155,10 @@ stateDiagram-v2 state "записи нет — реализована" as D state "ROADMAP.md, «умеет» — цель достигнута" as A - [*] --> B: add + [*] --> B: add --type feature|fix|chore|research [*] --> P: add --type goal B --> P: edit --type goal --section - P --> B: edit --type task --section + P --> B: edit --type feature|fix|chore|research --section B --> S: sprint take S --> B: sprint drop --reason S --> D: close --implemented @@ -169,7 +181,7 @@ stateDiagram-v2 ## Цели -**Цель — возможность приложения.** Такой же файл в `items/`, тип `[goal]`, +**Цель — возможность приложения.** Такой же файл в `items/`, тип `goal` (🎯), перечисленный в `ROADMAP.md`. Формулируется ответом на вопрос **«что приложение будет уметь»**, а не названием области работ: не «Работа с чтением», а «Чтение данных клиентами»; не «Рефакторинг слияния», а «Исход слияния не зависит от @@ -226,52 +238,51 @@ stateDiagram-v2 проектов. Встретился в чужом беклоге — это цель либо набор задач, и `check` назовёт его неизвестным типом. -## Род работы +## Тип записи -**Тип записи и род работы — две оси, и путать их нельзя.** Тип отвечает «что это -за запись» (цель, идея, задача), род — «какого рода работа»: `feature`, `fix`, -`chore`, `research`. Одним значением на оба вопроса не ответить: идея бывает -*про* функцию, а цель функцией *и является*. +**Тип — единственная ось, и он решает, что с записью можно делать.** Дом типа — +**поле меты `Тип` первой строкой**; эмодзи в заголовке H1 от него производна, её +ставит `add` и чинит `check --fix`. -- **`feature`** — снаружи появляется или меняется то, чего раньше не было. -- **`fix`** — поведение расходится с заявленным, и расхождение воспроизводится. - Не воспроизводится — это `research`, а не `fix`. -- **`chore`** — обслуживание: зависимости, сборка, перенос, чистка. Наблюдаемое - поведение не меняется, и в этом всё дело: **у `chore` тест готовности слабее - честно**, а не молча. «Что станет наблюдаемо иначе» здесь отвечается - разработчику («перестанет собираться два раза», «уедет последний вызов - устаревшего API»), а не пользователю. Пока рода не было, такие задачи либо не - заводились, либо формулировались как выдуманная польза. -- **`research`** — исход работы знание, а не изменение системы: ответ на вопрос, - замер, разведка. Приёмка — записанный ответ (`docs/research/`, ADR, тело - задачи), а не изменённый код. +| Тип | Обязательные разделы | Цель | В спринт | Устав | +| --- | --- | --- | --- | --- | +| 🎯 `goal` | `Завершение` | — | нет | [task-goal.md](references/task-goal.md) | +| ✨ `feature` | `Затрагивает`, `Критерии приёмки` | **обязательна** | да | [task-feature.md](references/task-feature.md) | +| 🐞 `fix` | `Воспроизведение`, `Затрагивает`, `Критерии приёмки` | необязательна | да | [task-fix.md](references/task-fix.md) | +| 🧹 `chore` | `Затрагивает`, `Критерии приёмки` | нет | да | [task-chore.md](references/task-chore.md) | +| 🔬 `research` | `Вопрос`, `Куда ляжет ответ` | нет | да | [task-research.md](references/task-research.md) | -Дом рода — **тег `kind:<род>`**, а не префикс заголовка и не поле меты: теги -здесь единственный механизм разметки, и `list --kind fix` работает даром. Цена -известна: в строку индекса род не попадает (индексы производны), и «в наборе одни -починки» видно командой, а не глазами по `SPRINT.md`. +Сверх обязательных у любой задачи допустимы `Рамки` и `Вопросы`. Раздел не из +схемы своего типа — **замечание, а не ошибка**: свой раздел законная вольность +проекта, но `Воспроизведение` у `chore` почти всегда значит, что тип проставлен +не тот, и сказать об этом стоит, не запрещая. + +**Осей было две, и ортогональность у них была фальшивой.** Тип записи +(`goal`/`idea`/`task`) и род работы (`kind:<род>` тегом) давали двенадцать клеток +произведения, из которых законны были шесть: у цели род запрещён, у задачи +обязателен, у идеи пуст. Плюс алгоритм работы крепится не к `task`, а к `fix` и +`research` — то есть к роду. Оси схлопнуты, тег `kind:` упразднён. + +**Тип `idea` упразднён вместе с ними.** Он значил не род работы, а **состояние +незаполненности** — «первый, второй или третий вопрос теста готовности не +отвечается», — а состояние типом быть не может: оно меняется по мере того, как +запись дописывают, а тип меняют командой. Теперь это состояние называется честно: +`research` без раздела «Вопрос» — **сырьё**. В спринт не берётся ровно как +прежняя идея, лежит в конце своей категории и отбирается `list --raw`. Словарь **закрыт**. Открытый разъедется на синонимах — `bug`, `bugfix`, `fix`, -`defect`, — и отбор по роду перестанет отвечать на свой единственный вопрос. Ни -один род не подходит — это сигнал, что в задаче их два и её надо разделить. +`defect`, — и отбор по типу перестанет отвечать на свой единственный вопрос. Ни +один тип не подходит — это сигнал, что в задаче их два и её надо разделить. -**Род обязателен у задачи, у цели запрещён, у идеи необязателен** — идея получает -его, когда становится задачей. Требуется он там, где по нему принимают решение: -`sprint take` без рода откажет. `check` о пропаже только **напоминает** — беклог, -заведённый до появления рода, законен, и переоформлять его «заодно» здесь не -просят. +**Требуется тип там, где по нему принимают решение:** `sprint take` без типа +откажет, потому что не знает, каких разделов требовать. `check` о пропаже только +**напоминает** — беклог, заведённый до появления типа, законен, и переоформлять +его «заодно» здесь не просят. -**Род решает и то, обязательна ли цель.** `feature` без цели не бывает: новая -возможность и есть содержание цели, и если подходящей нет — либо она заводится, -либо это не `feature`. `fix`, `chore` и `research` живут без цели законно, и -`check` о них молчит: они служат работоспособности, а не направлению. Это -единственный случай, когда род что-то определяет за пределами отбора, — и -определяет он учёт, а не процесс проверки. - -**Род не выбирает профиль ревью и вообще ничего не предписывает пайплайну.** -Профиль выбирается по факту изменения, а не по роду задачи: `chore` бывает +**Тип не выбирает профиль ревью и вообще ничего не предписывает пайплайну.** +Профиль выбирается по факту изменения, а не по типу задачи: `chore` бывает миграцией схемы, `fix` — правкой публичного контракта. Правило «предписание -процесса в теле задачи снимается» родом не отменяется, а подтверждается: он +процесса в теле задачи снимается» типом не отменяется, а подтверждается: он описывает работу, а не то, как её проверять. ## Как написана задача @@ -283,17 +294,18 @@ stateDiagram-v2 | Тип | Отвечает на | Пример | | --- | --- | --- | -| цель | что приложение будет уметь | Соперником может быть компьютер | -| задача | что нужно сделать | Печатать поле одним куском кода | -| идея | о чём она | Подсказка следующего хода | +| 🎯 `goal` | что приложение будет уметь | Соперником может быть компьютер | +| ✨ `feature`, 🐞 `fix`, 🧹 `chore` | что нужно сделать | Печатать поле одним куском кода | +| 🔬 `research` | о чём разведка | Подсказка следующего хода | Задача — **глаголом в неопределённой форме**, перед ним допускается «не»: «Не отбрасывать молча лишние символы в ходе», а не «Лишние символы молча отбрасываются». Описательный заголовок называет **состояние**, а из состояния не видно, чего от работы ждут: «Ничья объявляется, пока клетки есть» одинаково читается и как жалоба, и как задание, — и в списке, где решают «брать или не -брать», это разные вещи. Идея формы действия не несёт **намеренно**: что делать, -ещё неизвестно, и заголовок-действие обещал бы решённость, которой нет. +брать», это разные вещи. `research` формы действия не несёт **намеренно**: её +исход знание, что делать — ещё неизвестно, и заголовок-действие обещал бы +решённость, которой нет. Из этого же правила растёт разница индексов: роадмап — список возможностей, беклог — список работ, и если заголовки перепутать формами, каждый из них @@ -336,7 +348,7 @@ stateDiagram-v2 И одно требование, которое есть только у задачи: **сложность формулировки — не признак сложности работы.** Задачу, которую не удаётся сказать просто, чаще -всего не удаётся и оценить: это либо две задачи, либо идея. +всего не удаётся и оценить: это либо две задачи, либо сырьё. Эти правила — про **язык**, а не про объём: короткая задача без границ хуже длинной с ними. @@ -349,10 +361,10 @@ stateDiagram-v2 ``` python3 $tk check --dir D # согласованность индексов + здоровье -python3 $tk check --dir D --fix # + починить дрейф (секция, заголовок, дубли, «зачем», форма меты) -python3 $tk list --dir D [--stale] [--section S] [--type T] [--kind K] [--tag a,b] [--goal S] [--index …] [--questions] -python3 $tk add --dir D --slug S --title T [--type goal|idea] [--section S] [--goal G] [--kind K] [--why «зачем»] [--tag a,b] -python3 $tk edit S --dir D [--title T] [--why «зачем»] [--type T] [--goal G] [--kind K] [--add-tag a,b] [--rm-tag c] +python3 $tk check --dir D --fix # + починить дрейф (тип, эмодзи, место, заголовок, дубли, «зачем», форма меты) +python3 $tk list --dir D [--stale] [--section S] [--type T] [--tag a,b] [--goal S] [--raw] [--index …] [--questions] +python3 $tk add --dir D --slug S --title T --type goal|feature|fix|chore|research [--section S] [--goal G] [--why «зачем»] [--tag a,b] +python3 $tk edit S --dir D [--title T] [--why «зачем»] [--type T] [--goal G] [--add-tag a,b] [--rm-tag c] python3 $tk move S --dir D --section S [--reason R] [--after S | --first] python3 $tk close S --dir D --reason R # в REJECTED.md + удалить (ушла без реализации) python3 $tk close S --dir D --implemented # просто удалить (реализована и закоммичена) @@ -375,20 +387,22 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап Различать 1 и 3 обязательно: «дрейф в беклоге» — рабочая ситуация, «каталога нет» — нерабочая, и одинаковая реакция на них была бы неверна в обоих случаях. -Тип — английское ключевое слово `goal` / `idea` / `task` (как и прочие токены -команд); `task` префикса не несёт, остальные кодируются `[goal]`/`[idea]` в -заголовке. Текст задачи при этом русский. +Тип — английское ключевое слово `goal` / `feature` / `fix` / `chore` / +`research` (как и прочие токены команд), у `add` **обязательное**: без него +неизвестно, какой шаблон тела класть. Текст задачи при этом русский, а эмодзи в +заголовке ставит скрипт. **Мутации правят файл и индексы заодно** — руками строку индекса или мету не пиши, зови `add`/`edit`/`move`/`close`/`sprint`. Смена заголовка, «зачем», типа, -цели, рода работы и **тегов** — это `edit`: он держит H1, мету и индекс в синхроне. -Снятие тега — `--rm-tag` (после ответа на вопрос снимается `question`), смена -цели — `--goal`, рода — `--kind`; оба заменяют прежнее значение, а не добавляют -второе. +цели и **тегов** — это `edit`: он держит H1 (вместе с эмодзи), мету и индекс в +синхроне. Снятие тега — `--rm-tag` (после ответа на вопрос снимается +`question`), смена цели — `--goal`, типа — `--type`; оба заменяют прежнее +значение, а не добавляют второе. **Переезд между индексами — следствие смены типа, а не отдельная команда.** `edit --type goal --section <часть роадмапа>` переносит строку из -`BACKLOG.md` в `ROADMAP.md` (и обратно `--type task --section <секция беклога>`); +`BACKLOG.md` в `ROADMAP.md` (и обратно — задачным типом плюс +`--section <категория беклога>`); `move` двигает только внутри одного индекса и пишет причину. `--section` у `edit` работает **только** при таком переезде — иначе он отсылает к `move`, потому что смена секции без причины и есть тот дрейф, который потом никто не @@ -401,39 +415,54 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап `check` — единственный судья согласованности; что именно он ловит, скажет его вывод, здесь не пересказываем. Гоняй его **в начале сессии** и **после каждой правки**, даже если правил мутациями: дрейф мог накопиться раньше. Накопившееся -чини `check --fix` — он детерминированно правит то, где истина однозначна -(секция, заголовок, дубли, «зачем» из индекса в файл, старая форма меты, -пометка `decomposed` у цели с задачами), а неоднозначное (задача сразу в двух -индексах, нечего восстанавливать) печатает отдельной пометкой `НЕОДНОЗНАЧНО` — -это тебе, и это идёт строкой доклада. **Ссылка на исчезнувший файл в пометку не -попадает:** `--fix` её просто не трогает, и она остаётся `ОШИБКА` обычного -`check` — то есть видна, но в докладе её надо назвать отдельно. +чини `check --fix` — он детерминированно правит то, где истина однозначна (тип в +своё поле, эмодзи заголовка, имя поля места, секция, заголовок, дубли, «зачем» из +индекса в файл, старая форма меты, пометка `decomposed` у цели с задачами, сырьё +в конец категории), а неоднозначное (задача сразу в двух индексах, нечего +восстанавливать, **тип, которого неоткуда взять**) печатает отдельной пометкой +`НЕОДНОЗНАЧНО` — это тебе, и это идёт строкой доклада. **Ссылка на исчезнувший +файл в пометку не попадает:** `--fix` её просто не трогает, и она остаётся +`ОШИБКА` обычного `check` — то есть видна, но в докладе её надо назвать отдельно. -`--fix` правит **и файлы** — ровно в двух местах, где источник ровно один и -выбирать не из чего: «зачем», оставшееся только в индексе, переезжает в мету, -и цель, у которой есть задачи, получает тег `decomposed`. Оба случая печатаются -поимённо. +`--fix` правит **и файлы** — там, где источник ровно один и выбирать не из чего: +тип переезжает из прежнего дома (тег `kind:`, префикс `[goal]`/`[idea]`) в поле +меты, заголовок получает эмодзи, поле места — имя по типу, «зачем», оставшееся +только в индексе, переезжает в мету, цель с задачами получает `decomposed`. +Каждый случай печатается поимённо. + +**Тип, который не выводится ниоткуда, `--fix` не угадывает.** `feature` от +`chore` машина не отличает, и подставленное наугад значение врало бы ровно там, +где по нему принимают решение. Такие записи идут в `НЕОДНОЗНАЧНО`, и тип им +проставляет человек — `edit <слаг> --type …`. **Что механизировано, а что нет.** У задачи, взятой в набор (`sprint take` и -`check` по задачам спринта), проверяются три вещи, и у каждой своя глубина: +`check` по задачам спринта), проверяется схема её типа, и у каждой части своя +глубина: -- **критерии приёмки** — число пунктов жёстко (меньше двух отказ, больше пяти - замечание), наличие оракула **эвристикой** по слову «оракул» в пункте; -- **род работы** — жёстко: назван и из закрытого словаря; -- **раздел «Затрагивает»** — только **наличие непустого**. Полнота перечня машине - не видна: границу, которую забыли назвать, она от отсутствующей не отличает. +- **тип** — жёстко: назван и из закрытого словаря; +- **критерии приёмки** (`feature`, `fix`, `chore`) — число пунктов жёстко + (меньше двух отказ, больше пяти замечание), наличие оракула **эвристикой** по + слову «оракул» в пункте; +- **прочие разделы схемы** (`Затрагивает`, `Воспроизведение`, `Вопрос`, + `Куда ляжет ответ`, `Завершение`) — только **наличие непустого**. Содержимое + машине не видно: границу, которую забыли назвать, она от отсутствующей не + отличает, а шаги, по которым ничего не воспроизводится, — от годных. Настоящий оракул от слова «оракул» машина тоже не отличает, поэтому эвристика -даёт только замечание, и в докладе это называется как есть: «проверено число -пунктов и наличие границ, годность оракулов и полнота границ — глазами». +даёт только замечание, и в докладе это называется как есть: «проверено наличие +разделов своего типа и число критериев, годность оракулов и полнота границ — +глазами». -Формат файла, меты, слага, индексов и `REJECTED.md` — -[references/task-format.md](references/task-format.md). Там же тест «готова к -взятию», требования к критериям приёмки и раздел «Затрагивает». +Формат записи, меты, слага, индексов и `REJECTED.md` — +[references/task-format.md](references/task-format.md); там же тест «готова к +взятию». Схема и алгоритм каждого типа — по файлу на тип: +[goal](references/task-goal.md) · [feature](references/task-feature.md) · +[fix](references/task-fix.md) · [chore](references/task-chore.md) · +[research](references/task-research.md). ## Сценарии -### Завести задачу, идею или цель из диалога +### Завести запись из диалога 1. **Фильтр.** Делаем прямо сейчас — не заводим. Не пожалеем о потере — не заводим. Родилось три кандидата — покажи их и спроси, какие заводить: молча @@ -444,30 +473,36 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап ту строку и что изменилось с момента отказа (`add` предупредит и сам, но молча заводить нельзя). Две задачи об одном — самая дорогая находка переоценки. -3. **Тип по тесту готовности** (см. task-format): проходит — задача, не - проходит — идея (`--type idea`). Не делается одним заходом — это не эпик, а - несколько задач под одной целью: дроби сразу. Возможность приложения, а не - шаг — цель (`--type goal`). -4. **Цель задачи — если род её требует.** У `feature` должен быть - `--goal <слаг>`: новая возможность и есть содержание цели. Подходящей нет — - либо она заводится (`--type goal`), либо перед тобой не `feature`. У `fix`, - `chore` и `research` цели может не быть вовсе, и придумывать её не надо. У - идеи цель проставляется, когда идея становится задачей. -5. **Род работы** — `--kind feature|fix|chore|research` (см. «Род работы»). Не - подходит ни один — задача не одна, разбирай. -6. `add …`, затем допиши тело редактором: одна фраза, **затрагиваемые границы**, - критерии приёмки с оракулами, рамки. «Зачем» отвечает «зачем нужна эта - задача» — состояние, остаток, боль, — а не пересказывает первый абзац, и - пишется **для человека**: не «канонизация внутри транзакции», а «тело 40 МиБ - держит блокировку 5 секунд, соседние доставки уходят в отказ». -7. `check`. +3. **Тип** — `--type` обязателен, и он же первое содержательное решение: + + - возможность приложения, а не шаг к ней → `goal`; + - снаружи появляется то, чего не было → `feature`; + - поведение расходится с заявленным и **воспроизводится** → `fix` + (не воспроизводится → `research`); + - обслуживание, наблюдаемое поведение не меняется → `chore`; + - исход — знание, а не изменение системы → `research`. + + Не подходит ни один — в записи их два, разбирай. Не проходит тест готовности + (см. task-format) — это **сырьё**: `--type research`, раздел «Вопрос» пока + пуст, место в конце категории. Не делается одним заходом — это не эпик, а + несколько задач под одной целью: дроби сразу. +4. **Цель — если тип её требует.** У `feature` должен быть `--goal <слаг>`: + новая возможность и есть содержание цели. Подходящей нет — либо она + заводится (`--type goal`), либо перед тобой не `feature`. У `fix`, `chore` и + `research` цели может не быть вовсе, и придумывать её не надо. +5. `add …`, затем допиши тело редактором **по схеме своего типа** — шаблон её + уже разложил, устав типа объясняет каждый раздел. «Зачем» отвечает «зачем + нужна эта задача» — состояние, остаток, боль, — а не пересказывает первый + абзац, и пишется **для человека**: не «канонизация внутри транзакции», а + «тело 40 МиБ держит блокировку 5 секунд, соседние доставки уходят в отказ». +6. `check`. ### Разобрать находки аудита или ревью Ревью и аудиты — тоже источник задач, но с зеркальной диалогу опасностью: не пять файлов из одной мысли, а сорок файлов из сорока сырых находок. Защита та же, что в самом ревью: кластеризация по причине, дедуп против живых и -`REJECTED.md`, находка без свидетельства → идея, а не задача, и карта кластеров +`REJECTED.md`, находка без свидетельства → сырьё (`research`), а не задача, и карта кластеров пользователю до создания файлов. Порядок, отображение серьёзности и привязка к целям — [references/from-review.md](references/from-review.md). @@ -481,7 +516,7 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап Если переводить надо не только задачи, а весь `docs/` — это скилл `av-dev-pm:canon`, и он зовёт этот сценарий сам на своём шаге. -### Декомпозиция и штурм идеи +### Декомпозиция и штурм сырья [references/split.md](references/split.md). Обе операции превращают одну запись в несколько, и у обеих есть проверяемый тест: части должны **мерджиться порознь** и @@ -551,10 +586,14 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап - **предписание процесса в теле** — «делать таким-то профилем ревью», «взять такой-то агент»: это второй дом для правила выбора и путь понизить требования решением, принятым до проектирования. Снимается; -- **род, разошедшийся с задачей** — задача заводилась починкой, а после разбора +- **тип, разошедшийся с задачей** — задача заводилась починкой, а после разбора оказалось, что поведение никогда и не было заявлено: это `feature`, а не `fix`. - Правится `edit --kind …`; род, оставшийся от прошлой формулировки, врёт - ровно там, где по нему отбирают; + Правится `edit --type …`; тип, оставшийся от прошлой формулировки, врёт + ровно там, где по нему отбирают, **и требует не тех разделов**: у брошенного + `fix` останется «Воспроизведение», которого нечем заполнить; +- **сырьё, у которого появился вопрос** — разведка обросла формулировкой, но + раздел «Вопрос» так и пуст: она числится сырьём и в спринт не берётся. + Записывается вопрос, и `check --fix` поднимает строку из конца категории; - **границы, названные вместо реализации** — «переписать хранилище на новый драйвер» в разделе «Затрагивает» это не граница, а замысел. Границы — `таблица points и её миграция`, `эндпоинт POST /ingest`, `формат отпечатка на @@ -619,7 +658,7 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап - **Развилки — пользователю.** Через `AskUserQuestion`, с уже сформулированным предварительным суждением (**рекомендация — первым вариантом**). Что выкинуть, - под какую цель отнести, какая рамка идеи верна — решение пользователя. Слаг, + под какую цель отнести, какая рамка разведки верна — решение пользователя. Слаг, формулировка, порядок строк в индексе — механика, делаем сами. - **Не больше трёх вопросов за раз.** Пачка длиннее трёх тяжела для ответа; решений больше — веди **несколько итераций** диалога по ≤3, а не один diff --git a/av-dev-pm/skills/tasks/references/from-review.md b/av-dev-pm/skills/tasks/references/from-review.md index 128f396..e9d77fe 100644 --- a/av-dev-pm/skills/tasks/references/from-review.md +++ b/av-dev-pm/skills/tasks/references/from-review.md @@ -23,8 +23,11 @@ - **Находка со свидетельством**, отложенная к исполнению → **задача**. Свидетельство и последствие переносим в тело — это её «почему», то самое, что переживает запись. -- **Находка без свидетельства / низкой уверенности** → **идея** (`[idea]`), а не - задача. Её судьба — штурм, где либо найдётся подтверждение, либо она уедет в +- **Находка без свидетельства / низкой уверенности** → **сырьё**: `research`, у + которого раздел «Вопрос» и есть недостающее свидетельство («при каких условиях + это воспроизводится»). Не `fix`: без `Воспроизведения` его в спринт не + возьмут, и правильно — чинить нечего, пока непонятно, что ломается. Судьба + сырья — штурм, где либо найдётся подтверждение, либо оно уедет в `REJECTED.md`. - **Уже починено по ходу ревью** → **ничего**. Починенное не заводим. - **Развилка, решённая при ревью** → ничего; решённая «потом» → задача с @@ -44,13 +47,13 @@ 4. **Разложи по целям — там, где цель нужна.** Большинство находок ревью это `fix` и `chore`, и **цель им не требуется**: они служат работоспособности, а не направлению, и в спринт входят помимо его цели. Придуманная им цель — - ровно то враньё, от которого спасает род работы. + ровно то враньё, от которого спасает тип. Цель обязательна у находки, которая оказалась **новой возможностью** - (`kind:feature`): нашлось поведение, которого никто не заказывал, и его надо + (`feature`): нашлось поведение, которого никто не заказывал, и его надо либо заказать целью, либо убрать. Подходящей цели нет — заведи её (`add --type goal --section Направления`) в том же проходе. -5. **Покажи карту до создания файлов.** Кластер → задача / идея / строка в +5. **Покажи карту до создания файлов.** Кластер → задача / сырьё / строка в пакетный файл / уже заведено / отброшено, и под какую цель — пачкой через `AskUserQuestion`. Это тот же барьер, что и «три кандидата» в интейке из диалога: массовое заведение файлов без подтверждения — ровно тот отказ, ради @@ -60,11 +63,12 @@ 6. **Заводи утверждённое** через `tasks.py add`, с тремя добавками: - **тег партии** — `--tag review-ГГГГ-ММ-ДД` (или `audit-<тема>`), чтобы весь заход разбора поднимался одной командой `list --tag …`; - - **род работы** — `--kind`. У находок ревью он **не по умолчанию `fix`**: - починкой считается расхождение с заявленным поведением, а находка «этого - свойства никто не заказывал» — это `feature`, находка «не знаем, как - поведёт себя драйвер» — `research`. Род, розданный оптом, врёт ровно там, - где по нему потом отбирают; + - **тип** — `--type`, и он **не по умолчанию `fix`**: починкой считается + расхождение с заявленным поведением, а находка «этого свойства никто не + заказывал» — это `feature`, находка «не знаем, как поведёт себя драйвер» — + `research`. Тип, розданный оптом, врёт ровно там, где по нему потом + отбирают, **и требует не тех разделов**: каждому `fix` придётся заполнить + `Воспроизведение`, а у находки без свидетельства его нет; - **провенанс в теле** — кто нашёл, каким проходом, с каким свидетельством. Без него через месяц не отличить проверенную находку от догадки. 7. `tasks.py check`. diff --git a/av-dev-pm/skills/tasks/references/split.md b/av-dev-pm/skills/tasks/references/split.md index 06dd0b1..ebba763 100644 --- a/av-dev-pm/skills/tasks/references/split.md +++ b/av-dev-pm/skills/tasks/references/split.md @@ -57,10 +57,10 @@ через квартал вопрос «куда делась задача X» отвечается строкой со ссылками на наследников, а не археологией git; - родитель осмыслен как **возможность**, а не как шаг → это цель. Тип на месте - не меняется (цель живёт в другом индексе): заводится `[goal]` в `ROADMAP.md`, + не меняется (цель живёт в другом индексе): заводится `--type goal` в `ROADMAP.md`, части получают `--goal <новый слаг>`, родитель закрывается с причиной-ссылкой. -**Промежуточного зонтика между целью и задачей нет.** Тип `[epic]` упразднён: +**Промежуточного зонтика между целью и задачей нет.** Тип `epic` упразднён: роль зонтика играет цель, а слишком крупный шаг дробится на шаги помельче под той же целью. Если частям нужен общий заголовок — значит у них общая возможность, и её надо назвать целью, а не заводить временный тип. @@ -73,10 +73,15 @@ спринт продолжается остальными. Части заводятся сразу под той же целью, но в текущий набор **не добавляются** — набор заморожен. -## Мозговой штурм идеи +## Мозговой штурм сырья -Идея (`[idea]`) не проходит тест «готова к взятию»: неясно, что именно делаем. -Штурм проясняет — и это **generative-операция, а не applicative**. +Сырьё — запись типа `research`, у которой раздел «Вопрос» пуст: она не проходит +тест «готова к взятию», потому что неясно, что именно делаем. Штурм проясняет — +и это **generative-операция, а не applicative**. + +Исход штурма и есть заполненный «Вопрос» (тогда разведку можно брать в спринт) +или набор задач с типами, которые из ответа следуют. Третий законный исход — +`close --reason`. Applicative-штурм («перечисли задачи, следующие из идеи») выдаёт очевидное: перечисляется то, что уже видно в формулировке. Ценное — на уровень выше. diff --git a/av-dev-pm/skills/tasks/references/task-chore.md b/av-dev-pm/skills/tasks/references/task-chore.md new file mode 100644 index 0000000..3ec2d15 --- /dev/null +++ b/av-dev-pm/skills/tasks/references/task-chore.md @@ -0,0 +1,60 @@ +# 🧹 `chore` — обслуживание, наблюдаемое поведение не меняется + +Зависимости, сборка, перенос, чистка, оснастка. Отвечает на **«что нужно +сделать»**, глаголом в неопределённой форме. + +Общая форма записи (мета, слаг, строка индекса) — [task-format.md](task-format.md). +Здесь только то, что у этого типа своё. + +## Схема + +| | | +| --- | --- | +| Заголовок отвечает на | что нужно сделать | +| Обязательные разделы | `Затрагивает`, `Критерии приёмки` | +| Допустимые сверх того | `Рамки`, `Вопросы` | +| Поле места | **Категория** — полка домена беклога | +| Цель (`goal:<слаг>`) | нет: цель — это возможность, а здесь её не появляется | +| Индекс | `BACKLOG.md` → `SPRINT.md` | +| Берётся в спринт | да | + +## Адресат — разработчик, и это законно + +Тест готовности спрашивает «что станет наблюдаемо иначе». У `chore` ответ +адресован **разработчику**, а не пользователю: «перестанет собираться два раза», +«уедет последний вызов устаревшего API», «проверки гоняются одной командой». +Это ответ, а не отговорка. + +**У `chore` тест готовности слабее честно, а не молча.** Пока типа не было, +такие задачи либо не заводились вовсе, либо формулировались как выдуманная +пользовательская польза — и то и другое хуже, чем сказать прямо, для кого работа. + +Отсюда же граница: если после задачи меняется то, что видит пользователь, — это +не `chore`. Тип, оставшийся от первой формулировки, врёт ровно там, где по нему +отбирают. + +## Алгоритм + +1. **Проверить, что поведение не меняется.** Меняется — это `feature` или `fix`, + и у неё другие требования (цель, воспроизведение). +2. **Назвать, что перестанет мешать** — одной фразой, адресуясь разработчику. + «Прибраться в модуле X» ответом не является: непонятно, что изменится. +3. **Назвать границы** в `Затрагивает`. У обслуживания они часто не в коде: + конфиг и его образцы, версия зависимости, команда сборки, файл CI. Границей + считается то, у чего есть внешняя сторона и цена изменения. +4. **Написать критерии приёмки** — 2–5 утверждений с оракулами. У `chore` + оракул обычно самый дешёвый из всех типов: команда, которая раньше падала + или требовала трёх шагов, теперь отрабатывает одним. +5. **Проверить, что это не «заодно».** Обслуживание любит склеиваться в пачку + («обновить зависимости и переписать сборку и убрать мёртвый код»). Не + мерджится порознь — это несколько задач ([split.md](split.md)). +6. **Цель не проставлять.** `chore` служит работоспособности, а не направлению, + и в набор спринта входит помимо его цели. Работа по сопровождению проекта + при этом видна в роадмапе — секцией `Сопровождение`, но целью не становится. + +## Что видит машина, а что человек + +`check` и `sprint take` смотрят на **наличие непустого** `Затрагивает` и на +**число** критериев — ровно то же, что у `feature`. Разница между типами здесь не +в строгости проверки, а в том, **кому адресован ответ** на «что станет +наблюдаемо иначе», — и это судит человек. diff --git a/av-dev-pm/skills/tasks/references/task-feature.md b/av-dev-pm/skills/tasks/references/task-feature.md new file mode 100644 index 0000000..ec2a27f --- /dev/null +++ b/av-dev-pm/skills/tasks/references/task-feature.md @@ -0,0 +1,63 @@ +# ✨ `feature` — снаружи появляется то, чего не было + +Задача, после которой наблюдаемое поведение меняется в сторону новой +возможности. Отвечает на **«что нужно сделать»** и пишется глаголом в +неопределённой форме. + +Общая форма записи (мета, слаг, строка индекса) — [task-format.md](task-format.md). +Здесь только то, что у этого типа своё. + +## Схема + +| | | +| --- | --- | +| Заголовок отвечает на | что нужно сделать («Печатать поле одним куском кода») | +| Обязательные разделы | `Затрагивает`, `Критерии приёмки` | +| Допустимые сверх того | `Рамки`, `Вопросы` | +| Поле места | **Категория** — полка домена беклога | +| Цель (`goal:<слаг>`) | **обязательна** | +| Индекс | `BACKLOG.md` → `SPRINT.md` | +| Берётся в спринт | да | + +**Цель обязательна, и это единственный тип, у которого так.** Новая возможность +и есть содержание цели: подходящей нет — либо она заводится, либо перед тобой не +`feature`. `sprint take` без цели откажет. + +## Алгоритм + +1. **Найти цель или завести её.** Задача без цели, названная функцией, — самый + частый способ пронести в беклог работу, которой никто не заказывал. +2. **Назвать границы** в разделе `Затрагивает`: эндпоинт или команда, таблица и + миграция, формат на диске, публичный тип пакета, внешний сервис. Названы + **границы, а не замысел**: «переписать хранилище на новый драйвер» — замысел, + `таблица points и её миграция` — граница. Проверяется вопросом «это можно + назвать до того, как решено *как* делать?». +3. **Написать критерии приёмки** — 2–5 проверяемых утверждений списком, у + каждого назван оракул. Не «работает корректно», а «повторный прогон даёт тот + же отпечаток — оракул: команда сверки». +4. **Сказать, какую строку «Завершения» цели задача двигает.** Одной строкой в + теле. Это защита от задачи «отрефакторить X»: она проваливается не потому, + что невидима снаружи, а потому, что не находит строки, к которой относится. +5. **Проверить, что задача одна.** Отвечается всё, но задача не делается одним + заходом и не мерджится целиком — это несколько задач под одной целью, дроби + сразу ([split.md](split.md)). Промежуточного зонтика между целью и задачей + нет. +6. **Реализация** — дело пайплайна проекта, не этого скилла. Закрывается + `close <слаг> --implemented`: файл и строка удаляются, суть переезжает в + `openspec/specs/` и документацию. + +## Что видит машина, а что человек + +`check` и `sprint take` смотрят на **наличие непустого** раздела `Затрагивает`, +на **число** критериев (меньше двух — отказ, больше пяти — замечание) и на цель. +Наличие оракула проверяется **эвристикой** — словом «оракул» в пункте. + +Полнота перечня границ машине не видна: границу, которую забыли назвать, она от +отсутствующей не отличает. Настоящий оракул от слова «оракул» тоже не отличает. +Поэтому в докладе это называется как есть: «проверено число пунктов и наличие +границ, годность оракулов и полнота границ — глазами». + +**Критерии — пол, но расхождение с ними есть дефект критериев.** Видишь, что +критерии закрыты, а суть задачи не достигнута — **правь критерии и возвращай +задачу**, а не держи невидимое сверх-требование: иначе исполнитель никогда не +знает, закончил ли, и мотивирован занижать критерии заранее. diff --git a/av-dev-pm/skills/tasks/references/task-fix.md b/av-dev-pm/skills/tasks/references/task-fix.md new file mode 100644 index 0000000..22b9d90 --- /dev/null +++ b/av-dev-pm/skills/tasks/references/task-fix.md @@ -0,0 +1,70 @@ +# 🐞 `fix` — поведение расходится с заявленным + +Задача о расхождении между тем, что система делает, и тем, что про неё заявлено +— в спеке, в инварианте `CLAUDE.md`, в критериях закрытой задачи. Отвечает на +**«что нужно сделать»**, глаголом в неопределённой форме, перед ним допускается +«не»: «Не отбрасывать молча лишние символы в ходе». + +Общая форма записи (мета, слаг, строка индекса) — [task-format.md](task-format.md). +Здесь только то, что у этого типа своё. + +## Схема + +| | | +| --- | --- | +| Заголовок отвечает на | что нужно сделать | +| Обязательные разделы | **`Воспроизведение`**, `Затрагивает`, `Критерии приёмки` | +| Допустимые сверх того | `Рамки`, `Вопросы` | +| Поле места | **Категория** — полка домена беклога | +| Цель (`goal:<слаг>`) | необязательна | +| Индекс | `BACKLOG.md` → `SPRINT.md` | +| Берётся в спринт | да | + +## `Воспроизведение` — раздел, которого нет у других типов + +**Не воспроизводится — это `research`, а не `fix`.** Правило было записано и +раньше, но проверять его было нечем, и «починки» без единого шага повторения +уходили в спринт наравне с остальными. Раздел делает правило проверяемым: он +называет, **что сделать, чтобы расхождение проявилось, и что при этом видно +вместо ожидаемого**. + +Пишется двумя частями, обе обязательны по смыслу: + +- **шаги или вход** — команда, запрос, файл, последовательность действий; +- **что видно и что ожидалось** — «ввод `а1б2` ходит в `a1`, а должен быть + отвергнут с ошибкой». + +Это не критерии приёмки и не дублирует их: воспроизведение описывает **сегодня**, +критерии — **завтра**. Пропущенное воспроизведение чаще всего означает одно из +двух: расхождение приняли на слово, или его вообще нет, а есть недовольство +поведением — и тогда это `feature`, а не `fix`. + +## Алгоритм + +1. **Воспроизвести.** Не удаётся — это `research`: заведи вопрос «при каких + условиях проявляется» и не притворяйся, что чинить есть что. +2. **Найти, чему поведение противоречит.** Спека, инвариант, критерий закрытой + задачи. Не противоречит ничему — это `feature`: поведение никогда и не было + заявлено, а тип, оставшийся от первой формулировки, врёт ровно там, где по + нему отбирают. +3. **Записать воспроизведение** — шаги и наблюдаемое против ожидаемого. +4. **Назвать границы** в `Затрагивает`: починка часто трогает больше, чем + кажется по объёму текста, и оценка систематически занижена именно здесь. +5. **Написать критерии приёмки** — 2–5 утверждений с оракулами. У починки + почти всегда есть парный критерий: **прежнее поведение не сломалось** + («ввод `а1` принимается по-прежнему»). Без него починка чинит одно и ломает + соседнее. +6. **Цель не выдумывать.** `fix` служит работоспособности, а не направлению, и в + набор спринта входит помимо его цели. Придуманная цель — то же враньё, от + которого спасает тип. +7. **Записать дефект в журнал** `docs/review.md` с пометкой «проскочил / пойман + ревью». Проскочившие — эвал-сет для калибровки конвейера; пойманные с + оракулом — лучшая опора для прохода ревью: проектные, воспроизводимые, + однажды оказавшиеся правдой. + +## Что видит машина, а что человек + +`check` и `sprint take` смотрят на **наличие непустого** `Воспроизведения` и +`Затрагивает` и на **число** критериев. Годность воспроизведения — человеку: +шаги, по которым ничего не воспроизводится, машина от годных не отличает, и +делать вид, что проверено больше проверенного, хуже, чем не проверять вовсе. diff --git a/av-dev-pm/skills/tasks/references/task-format.md b/av-dev-pm/skills/tasks/references/task-format.md index a3db50c..bf6289f 100644 --- a/av-dev-pm/skills/tasks/references/task-format.md +++ b/av-dev-pm/skills/tasks/references/task-format.md @@ -1,78 +1,125 @@ -# Формат задач, целей и индексов +# Формат записей и индексов Заголовок, мета-блок и строку индекса ставит `tasks.py add` — руками их не -пишут. Этот файл описывает, что именно скрипт создаёт и что проверяет `check`; -тело задачи (одну фразу, критерии, рамки, контекст) дописывает агент. +пишут. Этот файл описывает **общую форму** любой записи и то, что проверяет +`check`; тело дописывает агент. -## Файл задачи +Чем разделы тела отличаются от типа к типу, какой алгоритм у каждого типа и что +у него обязательно — **отдельным файлом на тип**: + +| Тип | Файл | Одной строкой | +| --- | --- | --- | +| 🎯 `goal` | [task-goal.md](task-goal.md) | возможность приложения | +| ✨ `feature` | [task-feature.md](task-feature.md) | снаружи появляется то, чего не было | +| 🐞 `fix` | [task-fix.md](task-fix.md) | поведение расходится с заявленным | +| 🧹 `chore` | [task-chore.md](task-chore.md) | обслуживание, поведение не меняется | +| 🔬 `research` | [task-research.md](task-research.md) | исход — знание, а не изменение | + +## Файл записи `items/.md`: ```markdown -# Тай-брейк при равной полноте +# 🐞 Не отбрасывать молча лишние символы в ходе -- **Секция:** Ядро — вышла из спринта: остаток писал нерешённое в журнал -- **Зачем:** порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт -- **Теги:** goal:merge-robustness, kind:fix, sprint:2026-08-03 +- **Тип:** fix +- **Категория:** Ядро — вышла из спринта: остаток писал нерешённое в журнал +- **Зачем:** ввод «а1б2» ходит в a1 — игрок не видит, что ошибся, и винит игру +- **Теги:** goal:merge-robustness, sprint:2026-08-03 -При столкновении точек выигрывает более полная, но при равной полноте побеждает -последняя доставка — а она систематически беднее первой. +Разбор хода читает первые два символа и молча выбрасывает остаток строки. + +## Воспроизведение + +Ввести `а1б2` в свой ход: программа ходит в `a1` и ничего не сообщает. +Ожидалось — отказ с ошибкой разбора. ## Затрагивает -Таблица `points` и её миграция; правило слияния в приёме доставки; формат -отпечатка состояния на диске. Публичного контракта не трогает. +Разбор строки хода; текст ошибки в выводе партии. Формат сохранения партии +не трогается. ## Критерии приёмки -- повторный прогон свёртки даёт тот же отпечаток состояния — оракул: команда сверки -- накопительная метрика за сутки не уменьшается после повторной доставки — оракул: тест -- в логе видно, какая из двух точек выиграла и почему — оракул: глазами по логу прогона +- ввод «а1б2» отвергается с ошибкой — оракул: тест разбора +- ввод «а1» принимается по-прежнему — оракул: тест разбора ## Рамки -Схема не трогается; данные только читаются; перезапуск сервиса допустим. +Схема не трогается; данные только читаются; перезапуск допустим. Связано: решение о канонической форме содержимого. ``` -- **Заголовок H1** — он же заголовок строки в индексе, дословно. Тип кодируется - префиксом `[goal]` / `[idea]`; обычная задача — без префикса. - Отдельного поля типа **нет**: два места для одного факта разъезжаются, а - префикс виден прямо в индексе, где и принимается решение «брать или не брать». -- **Форма заголовка — по типу записи.** Задача отвечает на «что нужно сделать» - и пишется глаголом в неопределённой форме («Печатать поле одним куском кода», - «Не отбрасывать молча лишние символы»); цель — на «что приложение будет - уметь»; идея просто называет, о чём она. Почему так — SKILL.md, «Как написана +- **Заголовок H1** — он же заголовок строки в индексе, дословно. Начинается + **эмодзи типа**, и она **производна**: её ставит `add` и чинит `check --fix` + по полю меты. Второго дома у типа нет — эмодзи это его отображение, как + строка индекса это отображение файла. +- **Форма заголовка — по типу.** Цель отвечает на «что приложение будет уметь»; + `feature`, `fix` и `chore` — на «что нужно сделать», глаголом в неопределённой + форме, перед ним допускается «не»; `research` называет предмет разведки и + формы действия **не несёт намеренно**. Почему так — SKILL.md, «Как написана задача». `check` считает заголовки не в форме действия и печатает число в здоровье; годность формулировки смотрит агент `task-form`. -- **Мета-блок** — список сразу после заголовка, **поле на строку**. Обязательна - секция, причина после тире желательна (именно она объясняет, почему задача - здесь оказалась — в том числе «вышла из спринта: …»), «зачем» и теги - опциональны. Порядок свободный, поле в одну строку. Нераспознанные поля - сохраняются: скрипт правит свои и не трогает чужие. +- **Мета-блок** — список сразу после заголовка, **поле на строку**. Обязательны + **тип** и **место**, причина после тире желательна (именно она объясняет, + почему задача здесь оказалась — в том числе «вышла из спринта: …»), «зачем» и + теги опциональны. Нераспознанные поля сохраняются: скрипт правит свои и не + трогает чужие. +- **Тип — первым полем.** Он решает, что у записи вообще может быть: какие + разделы обязательны, нужна ли цель, берётся ли она в спринт, — и читается + раньше всего остального. Словарь **закрыт**: `goal` | `feature` | `fix` | + `chore` | `research`. Не подходит ни один — это сигнал, что в записи их два и + её надо разделить. - **«Зачем» отвечает на «зачем нужна эта задача»** — состояние, остаток, боль. Не пересказ задачи: пересказ уже есть по ссылке. Живёт здесь, а не только в индексе: строка индекса его повторяет и производна от него, `check` сверяет, `check --fix` восстанавливает пропавшую строку **вместе с ним**. Пока поле лежало только в индексе, штатная починка дрейфа теряла его молча и навсегда — а это единственное, по чему задачу выбирают, не открывая. - -- **Тело** — одна фраза «что станет наблюдаемо иначе», затрагиваемые границы, - критерии приёмки, рамки, контекст, ссылки. Пишется на языке документации - проекта: предметно, без англицизмов, у которых есть русское слово, и без - терминов, которых нет ни в паспорте, ни в архитектуре, ни в конвенциях - (правило и его причина — в SKILL.md, раздел «Как написана задача»). - -Мета **одной строкой через `·`** — прежняя форма. Она читается по-прежнему, -`check` называет её дрейфом, `check --fix` переписывает списком; поле `Хук` -при этом становится `Зачем`. Причина отказа от строки простая: с тремя полями -и длинным «зачем» строка уезжала за экран, а `·` приходилось запрещать в тексте -причины и самого «зачем». +- **Тело** — одна фраза «что станет наблюдаемо иначе», дальше разделы по схеме + типа. Пишется на языке документации проекта: предметно, без англицизмов, у + которых есть русское слово, и без терминов, которых нет ни в паспорте, ни в + архитектуре, ни в конвенциях (правило и его причина — в SKILL.md, раздел «Как + написана задача»). Тело — не план реализации и не спецификация: принятое и реализованное переезжает в документацию проекта, а файл задачи удаляется. +### Поле места: «Категория» и «Секция» + +Поле называет, **где числится строка**, и имя у него **зависит от типа**: + +| Тип | Поле | Значения | Что это | +| --- | --- | --- | --- | +| `goal` | **Секция** | `Запланировано`, `Направления`, `Сопровождение` | часть роадмапа: состояние очереди | +| прочие | **Категория** | секции беклога проекта (`Ядро`, `Инфра`, …) | полка домена, в которую задача вернётся из спринта | + +Разные имена потому, что это **разные вещи**. У задачи поле переживает спринт: +`sprint drop` возвращает её именно туда. У цели оно называет не полку, а место в +очереди работ. Одно имя на два смысла и было конфляцией; `check` называет +несовпадение дрейфом, `check --fix` переименовывает. + +Имя самого места принадлежит **заголовку индекса** — файл на него лишь +ссылается, и принадлежность сверяется по нижнему регистру. + +### Прежние формы, которые читаются, но не пишутся + +Всё это `check` называет дрейфом, а `check --fix` переписывает: + +| Было | Стало | +| --- | --- | +| префикс `[goal]` / `[idea]` в H1 | поле **Тип** + эмодзи в H1; `[idea]` → `research` | +| тег `kind:<род>` | поле **Тип** (род работы стал типом) | +| поле **Секция** у задачи | поле **Категория** | +| поле **Хук** | поле **Зачем** | +| мета одной строкой через `·` | мета списком, поле на строку | + +Единственное, чего `--fix` не делает сам, — **проставить тип записи, у которой +его неоткуда взять**: `feature` от `chore` машина не отличает, и подставленное +наугад значение врало бы ровно там, где по нему принимают решение. Такие записи +он называет поимённо пометкой `НЕОДНОЗНАЧНО`. + ### Затрагивает Перечень **границ**, которых изменение касается. Границей считается то, у чего @@ -100,8 +147,8 @@ забыли назвать, она от отсутствующей не отличает. Раздела нет — отказ во взятии: оценивать нечем. -**У идей раздела нет** — как и критериев: границы становятся известны, когда идея -превращается в задачу. +**У `goal` и `research` раздела нет** — у первой границы называют её задачи, у +второй они становятся известны, когда из разведки родятся задачи. ### Критерии приёмки @@ -118,7 +165,9 @@ замечание: настоящий оракул от слова «оракул» машина не отличает, и делать вид, что проверено больше проверенного, хуже, чем не проверять вовсе. -**У идей критериев нет — именно поэтому они идеи.** +**У `research` критериев нет** — её приёмка это записанный ответ, и описывается +она разделами «Вопрос» и «Куда ляжет ответ». **У `goal` их заменяет +«Завершение».** **Критерии — пол, но расхождение с ними есть дефект критериев.** Если приёмщик видит, что критерии закрыты, а суть задачи не достигнута, он **правит критерии и @@ -129,16 +178,21 @@ ### Рамки Одна строка: чего касаться нельзя, что перезапускается, что считается -необратимым, трогается ли схема данных. **Свойства репозитория сюда не пишутся** -— номер последней миграции, версия зависимости, хеш: в лежалой задаче они -протухают молча и становятся ложной рамкой. Снимок берётся при постановке, а не -при заведении. +необратимым, трогается ли схема данных. Раздел **допустим у любого типа задачи и +ни у одного не обязателен**. **Свойства репозитория сюда не пишутся** — номер +последней миграции, версия зависимости, хеш: в лежалой задаче они протухают +молча и становятся ложной рамкой. Снимок берётся при постановке, а не при +заведении. ### Вопросы Неразобранное решение человека живёт разделом `## Вопросы` **плюс тегом `question`**. Раздел без тега или тег без раздела — дрейф, `check` о нём скажет. +Раздел `Вопросы` (о решении человека) и раздел `Вопрос` у `research` (предмет +разведки) — **разные вещи и разные слова**: первый блокирует взятие, второй его +разрешает. + **Судит факт, а не метка.** Отказ во взятии даёт **непустой раздел «Вопросы»**, независимо от того, стоит ли тег: иначе забывший тег проходил бы, а поставивший спотыкался — стимул ровно обратный записанному правилу. Тег производен: он нужен @@ -158,13 +212,12 @@ ## Файл цели -**Заголовок цели отвечает на «что приложение будет уметь».** Не область работ и -не имя подсистемы: не «Работа со слиянием», а «Исход слияния не зависит от -порядка доставки». Свойство поведения — тоже возможность. +Форма та же, разделы и алгоритм — [task-goal.md](task-goal.md). ```markdown -# [goal] Исход слияния не зависит от порядка доставки +# 🎯 Исход слияния не зависит от порядка доставки +- **Тип:** goal - **Секция:** Направления - **Теги:** decomposed @@ -181,10 +234,6 @@ - **Задачи цели здесь не перечисляются.** Перечень даёт `tasks.py list --goal <слаг>`; хранимый список стал бы третьим индексом и поехал бы на первой же закрытой задаче. -- **Раздел «Завершение» — списком, а не абзацем.** Это признаки того, что - приложение уже умеет; **на строку «Завершения» ссылается задача**, объясняя, - какую часть возможности она двигает (см. тест готовности). Абзацем такая - ссылка не берётся, поэтому список. - **Тег `decomposed`** отличает «цель ещё не разобрана» от «все её задачи закрыты» — два состояния, у которых снаружи один и тот же признак: задач нет. Пометка именно **тегом**, а не строкой в теле: только так она проверяется. @@ -217,16 +266,18 @@ Строка везде одной формы: ```markdown -- [Заголовок дословно](items/slug.md) — зачем +- [🐞 Заголовок дословно](items/slug.md) — зачем ``` «Зачем» отвечает на «зачем нужна эта задача» одним предложением: состояние, остаток, боль. Пересказ первого абзаца бесполезен — он уже есть по ссылке. +Эмодзи внутри квадратных скобок не украшение: заголовок копируется **дословно**, +и тип виден там, где решают «брать или не брать». | Файл | Что отвечает | Секции | | --- | --- | --- | | `ROADMAP.md` | что приложение уже умеет и чего ещё не умеет | канонические и в этом порядке: `Запланировано`, `Направления`, `Сопровождение`, `Готово` (англ. `Planned`, `Directions`, `Operations`, `Done`) | -| `BACKLOG.md` | что **можно взять** — только задачи | секции проекта (по умолчанию Ядро/Инфра) | +| `BACKLOG.md` | что **можно взять** — только задачи | категории проекта (по умолчанию Ядро/Инфра) | | `SPRINT.md` | какая цель и какой набор под неё | одна: «Набор» | | `REJECTED.md` | что ушло без реализации и почему | — | @@ -237,8 +288,15 @@ следующем `sprint start` и очищается на `sprint close`. Секции — **единственные заголовки `##` в индексе**: любой другой `##` в -преамбуле проверка сочтёт секцией. Внутри секции беклога порядок значения не -имеет — порядка в беклоге нет вовсе. +преамбуле проверка сочтёт секцией. + +**Порядка «по важности» внутри секции беклога нет** — «что делать дальше» +отвечает набор спринта. Единственный порядок, который есть, **производен от типа +и заполненности**: **сырьё** (`research` без раздела «Вопрос») стоит в конце +своей секции, потому что его не берут, и между берущимся оно каждый раз требует +открыть файл, чтобы это понять. Проверяет `check`, переставляет `check --fix`, +и человек этот порядок не назначает — иначе он был бы приоритетом, которого +здесь нет. **Секции «блокеры» среди них нет.** Блокер — состояние, а не полка: он живёт до ответа человека, а следы остаются вопросами в файлах задач распущенного спринта. @@ -251,15 +309,14 @@ `REJECTED.md`, и по той же причине (файла уже нет, ссылаться некуда). **Секции роадмапа закреплены** — состав, полнота, единство языка и **порядок** -проверяются `check`; секции беклога проект называет сам. Почему так — SKILL.md. -Порядок закреплён потому, что `Готово` копится: стоя первым, достигнутое -отодвигает за экран то, ради чего роадмап открывают чаще всего. +проверяются `check`; категории беклога проект называет сам. Почему так — +SKILL.md. Порядок закреплён потому, что `Готово` копится: стоя первым, +достигнутое отодвигает за экран то, ради чего роадмап открывают чаще всего. **Заголовок секции пишется с прописной и отбивается пустой строкой с обеих -сторон** — во всех индексах, включая секции беклога, имена которых выбирает проект. Написание -канонических секций и отбивку правит `check --fix`; он же сводит написание -секции в мете файла с заголовком индекса — **имя секции принадлежит заголовку**, -файл на неё лишь ссылается, и принадлежность сверяется по нижнему регистру. +сторон** — во всех индексах, включая категории беклога, имена которых выбирает +проект. Написание канонических секций и отбивку правит `check --fix`; он же +сводит написание места в мете файла с заголовком индекса. Индексы **производны**: расходятся с файлом — правим индексы (`check --fix`). Строку руками не пишут. @@ -295,19 +352,13 @@ ## Теги -Единственный механизм разметки, потому что `list --tag` уже умеет отбирать по -ним порцию разбора. Отдельных полей меты под это не заводим. +Разметка сверх типа. Тип полем, потому что он один и обязателен; теги — потому +что их много и `list --tag` уже умеет отбирать по ним порцию разбора. -- `goal:<слаг>` — цель, которой служит задача. Обязателен **у `kind:feature`**: +- `goal:<слаг>` — цель, которой служит задача. Обязателен **у `feature`**: новая возможность и есть содержание цели. У `fix`, `chore` и `research` его может не быть — они служат работоспособности, а не направлению, и в набор спринта входят помимо его цели. -- `kind:<род>` — род работы: `feature` | `fix` | `chore` | `research`. Словарь - **закрыт**, значение ровно одно. Обязателен у задачи (без него `sprint take` - откажет), у цели запрещён, у идеи необязателен. Ставится - `add --kind` / `edit --kind`; `--kind` заменяет прежнее значение, а не - добавляет второе. Смысл рода и почему он тегом, а не префиксом — в SKILL.md, - раздел «Род работы». - `question` — в файле есть неразобранный раздел «Вопросы». - `sprint:<слаг>` — задача заведена в этом спринте; по нему отбирается первая порция разбора («урожай спринта»). **Ставится сам**: слаг спринта заводит @@ -317,6 +368,9 @@ разбора — урожай прошедшего спринта». - `decomposed` — на цели: разложена на задачи (см. «Файл цели»). +Тега `kind:<род>` больше нет: род работы стал типом. Оставшийся в файле `check` +называет дрейфом, а `check --fix` снимает, перенеся значение в поле «Тип». + Отбор — `list --tag a,b`: перечисленные через запятую теги требуются **все сразу** (это И, не ИЛИ). Тег, которого нет ни у одной задачи, `list` называет вслух: молчаливый ноль читается как «таких задач нет», а чаще это опечатка. @@ -327,17 +381,20 @@ ## Тест «готова к взятию» -Задача готова, если из файла отвечаются четыре вопроса: +Задача готова, если из файла отвечаются четыре вопроса. Первый и четвёртый — +общие, второй и третий у каждого типа свои и перечислены в его файле. 1. **Что станет наблюдаемо иначе**, когда она сделана — снаружи: пользователю, владельцу сервиса или разработчику. «Отрефакторить X» — не ответ; «перестанет - ломаться Y при Z» — ответ. **У `kind:chore` адресат — разработчик, и это + ломаться Y при Z» — ответ. **У `chore` адресат — разработчик, и это законно**: «уедет последний вызов устаревшего API» — ответ, а не отговорка. - Род объявлен как раз затем, чтобы такие задачи не выдумывали себе + Тип объявлен как раз затем, чтобы такие задачи не выдумывали себе пользовательскую пользу. -2. **Каких границ это касается** — раздел «Затрагивает». Без него задачу нельзя - оценить: остаётся судить по длине текста. -3. **По чему видно, что закончено** — критерии приёмки с оракулами. +2. **Что известно про сегодня** — то, что тип требует знать до работы: + у `fix` это `Воспроизведение`, у `research` — `Вопрос`, у `feature` и + `chore` — `Затрагивает`. +3. **По чему видно, что закончено** — критерии приёмки с оракулами; + у `research` вместо них `Куда ляжет ответ`. 4. **Какую часть «Завершения» своей цели она двигает** — у задачи с целью. Строкой: «двигает пункт 2 «Завершения» — накопительная метрика перестаёт уменьшаться». Это и есть защита от задачи «отрефакторить X»: она проваливает @@ -349,9 +406,10 @@ **У задачи без цели** (`fix`, `chore`, `research`) вопрос не задаётся: они служат работоспособности, а не направлению. -Не отвечается первый, второй или третий вопрос → это **идея** (`[idea]`), её -место в штурме. Не отвечается четвёртый у `feature` → либо цель есть и не -проставлена, либо это не новая возможность. +Не отвечается первый, второй или третий вопрос → это ещё не задача, а **сырьё**: +тип `research` без раздела «Вопрос», место — конец секции, работа над ним — +штурм. Не отвечается четвёртый у `feature` → либо цель есть и не проставлена, +либо это не новая возможность. Отвечается всё, но задача не делается одним заходом и не мерджится целиком → это **несколько задач под одной целью**, дроби сразу. Промежуточного зонтика diff --git a/av-dev-pm/skills/tasks/references/task-goal.md b/av-dev-pm/skills/tasks/references/task-goal.md new file mode 100644 index 0000000..9a22970 --- /dev/null +++ b/av-dev-pm/skills/tasks/references/task-goal.md @@ -0,0 +1,70 @@ +# 🎯 `goal` — возможность приложения + +Цель отвечает на **«что приложение будет уметь»**. Не область работ и не имя +подсистемы: не «Работа со слиянием», а «Исход слияния не зависит от порядка +доставки». Свойство поведения — тоже возможность. + +Общая форма записи (мета, слаг, строка индекса) — [task-format.md](task-format.md). +Здесь только то, что у этого типа своё. + +## Схема + +| | | +| --- | --- | +| Заголовок отвечает на | что приложение будет уметь | +| Обязательные разделы | `Завершение` | +| Допустимые сверх того | — | +| Поле места | **Секция** — часть роадмапа | +| Цель (`goal:<слаг>`) | запрещена: цель и есть цель | +| Индекс | `ROADMAP.md`, и никогда `BACKLOG.md` или `SPRINT.md` | +| Берётся в спринт | нет — берутся её задачи | + +Поле места у цели называется **«Секция»**, а не «Категория», и это не разнобой: +у задачи оно называет полку домена, в которую она вернётся из спринта, а у цели +— часть роадмапа, то есть состояние очереди. Одно имя на два смысла и было +конфляцией. + +## «Завершение» — списком, а не абзацем + +Это признаки того, что приложение **уже умеет**, и на строки этого раздела +ссылаются задачи цели: «двигает пункт 2 «Завершения» — накопительная метрика +перестаёт уменьшаться». Абзацем такая ссылка не берётся, поэтому список. + +Отсюда же читается обратное и более полезное: **строка «Завершения», к которой +не относится ни одна задача, — незакрытая часть возможности**. Достаточность +набора задач видна из самой цели, а не из чьей-то памяти. + +## Алгоритм + +1. **Проверить, что это возможность, а не работа.** Сборка, проверки, выкладка, + мониторинг, дежурство на вопрос «что приложение будет уметь» не отвечают. Им + отведена секция `Сопровождение` — там они видны в том же экране и не читаются + как обещание продукта. Граница проходит по тому, **кто наблюдает**: + «приложение сообщает о своём состоянии» — возможность, «дежурный видит + состояние на одном экране» — сопровождение. +2. **Выбрать секцию.** Очередь значима и обоснована прозой — `Запланировано`; + тянется долго и очереди не имеет — `Направления`; про то, чем держат проект, + — `Сопровождение`. В `Готово` кладёт сам `close`. +3. **Написать «Завершение»** — 2–5 наблюдаемых признаков списком. Пишутся до + декомпозиции: иначе задачи придумают себе цель задним числом. +4. **Разложить на задачи** и проставить им `goal:<слаг>`. Перечень задач в теле + цели **не хранится** — он был бы третьим индексом и поехал бы на первой же + закрытой задаче; выводит `tasks.py list --goal <слаг>`. +5. **Пометить `decomposed`.** Тег отличает «ещё не разобрана» от «все задачи + закрыты» — два состояния с одним внешним признаком. `check --fix` ставит его + сам цели, у которой задачи есть. +6. **Закрыть достигнутой** — `close <слаг> --implemented`, когда не осталось + открытых задач. Файл удаляется, строка с датой переезжает в `Готово`. Скрипт + откажет, если задачи ещё живы. + +## Что видит машина, а что человек + +`check` считает цели, различает разобранные и пустые, ставит `decomposed`, +запрещает закрыть цель с живыми задачами и держит `Секцию` в согласии с +заголовком роадмапа. **Годность формулировки — не машине**: «возможность это или +область работ» решает [агент вычитки](../SKILL.md#вычитка-два-прохода-а-не-один). + +Достигнутая цель **не исчезает**: «что приложение умеет» — половина вопроса, ради +которого роадмап открывают. Вторым домом поведения роадмап при этом не +становится: нормативное поведение живёт в `openspec/specs/`, роадмап отвечает, +**когда и в каком порядке** оно появилось. diff --git a/av-dev-pm/skills/tasks/references/task-research.md b/av-dev-pm/skills/tasks/references/task-research.md new file mode 100644 index 0000000..605bff3 --- /dev/null +++ b/av-dev-pm/skills/tasks/references/task-research.md @@ -0,0 +1,83 @@ +# 🔬 `research` — исход работы знание, а не изменение системы + +Ответ на вопрос, замер, разведка, проработка сырой мысли. Приёмка — **записанный +ответ**, а не изменённый код. + +Общая форма записи (мета, слаг, строка индекса) — [task-format.md](task-format.md). +Здесь только то, что у этого типа своё. + +## Схема + +| | | +| --- | --- | +| Заголовок отвечает на | о чём разведка (предмет, а не действие) | +| Обязательные разделы | `Вопрос`, `Куда ляжет ответ` | +| Допустимые сверх того | `Рамки`, `Вопросы` | +| Поле места | **Категория** — полка домена беклога | +| Цель (`goal:<слаг>`) | нет | +| Индекс | `BACKLOG.md` → `SPRINT.md` | +| Берётся в спринт | да — **но только с заполненным «Вопросом»** | + +**Критериев приёмки у `research` нет, и это не поблажка.** Критерии в форме +«оракул: тест» разведке натянуты: проверять нечего, пока ответа нет. Её приёмка +описывается раздельно — вопрос, на который отвечаем, и место, куда ляжет ответ. + +**Заголовок формы действия не несёт намеренно.** Что делать, ещё неизвестно, и +заголовок-действие обещал бы решённость, которой нет. «Подсказка следующего +хода», а не «Сделать подсказку следующего хода». + +## Этот тип вобрал прежний `[idea]` + +Тип `idea` упразднён. Он значил не род работы, а **состояние незаполненности** — +«первый, второй или третий вопрос теста готовности не отвечается», — а состояние +типом быть не может: оно меняется по мере того, как запись дописывают, а тип +меняют командой. + +Теперь это состояние называется честно: **`research` без раздела «Вопрос» — это +сырьё**. + +| | сырьё | разведка | +| --- | --- | --- | +| Раздел `Вопрос` | пуст или отсутствует | заполнен | +| `sprint take` | отказ | берёт | +| Место в секции беклога | **конец**, `check --fix` сносит туда сам | среди прочих | +| `tasks.py list --raw` | показывает | нет | + +Порядка «по важности» в беклоге по-прежнему нет. Этот порядок **производен от +типа и заполненности**, а не назначен человеком, — потому его и проверяет машина, +и потому он не противоречит правилу «порядка нет, есть цель». + +Сырьём заводится и **сырая функция**: «Подсказка следующего хода» — ещё не +`feature`, потому что неизвестно, что именно делать. Работа над ней — думание, и +её исход — либо задачи, либо отказ. + +## Алгоритм + +1. **Записать вопрос одной фразой.** Не тему, а вопрос: не «Разобраться с + выводом в терминалах», а «Какими символами рамки печатаются одинаково в + Терминале, iTerm и `tmux`». Вопроса ещё нет — запись заводится сырьём и + лежит в конце секции, пока вопрос не появится. +2. **Назвать, куда ляжет ответ**: `docs/research/<тема>.md`, ADR, тело этой + задачи. Место называется **заранее**, иначе ответ остаётся в переписке, а + через квартал разведку заказывают заново. +3. **Ограничить рамками**, если разведка может утечь: сколько времени, какие + источники, что заведомо вне. +4. **Провести разведку** и **записать ответ по названному адресу**. Числа — с + провенансом: с командой или условиями, которыми получены. Число без источника + проход ревью обязан читать как условие, а не как замер. +5. **Разложить исход на задачи** — если он их родил. Разведка кончается одним из + трёх: заведены задачи, записано знание, отказ. **Отказ — полноправный исход**: + «проверили, не проблема» экономит спринт. +6. **Закрыть** — `close <слаг> --implemented`, когда ответ записан. Файл + удаляется: запись ответа и есть след, второго не нужно. Ушла без ответа — + `close --reason`, и строка уезжает в `REJECTED.md`. + +## Что видит машина, а что человек + +`check` и `sprint take` смотрят на **наличие непустых** разделов `Вопрос` и +`Куда ляжет ответ`, считают сырьё отдельной строкой здоровья и держат его в конце +секции. Годность вопроса — человеку: «вопрос это или тема» машина не различает, +и `check` о годности молчит намеренно. + +Штурм сырья, дробление исхода на задачи и тест «части мерджатся порознь» — +[split.md](split.md). diff --git a/av-dev-pm/skills/tasks/scripts/tasks.py b/av-dev-pm/skills/tasks/scripts/tasks.py index 157d19a..5c79f86 100755 --- a/av-dev-pm/skills/tasks/scripts/tasks.py +++ b/av-dev-pm/skills/tasks/scripts/tasks.py @@ -27,9 +27,19 @@ av-dev, и подгоняется под него проект. Имена вн задача — знают индексы**, потому что «в спринте» это свойство спринта, а не задачи; поля-состояния в файле нет, а рассогласование ловит check. -Тип — ключевое слово (goal | idea | task), по-английски, как и прочие токены -команд. Обычная задача (task) префикса не несёт, остальные кодируются префиксом -`[goal]`/`[idea]` в заголовке H1. Отдельного поля типа нет. +Тип — единственная ось записи и **закрытый словарь из пяти значений**: +goal | feature | fix | chore | research, по-английски, как и прочие токены +команд. Дом типа — **поле меты `Тип` первой строкой**; эмодзи в заголовке H1 +производна от него, её ставит и чинит `check --fix`. Эмодзи нужна там, где +принимают решение «брать или не брать», — в строке индекса, а она копирует H1 +дословно. + +Прежних осей было две: тип записи (goal | idea | task) и род работы +(`kind:<род>` тегом). Ортогональность была фальшивой — из двенадцати клеток +произведения законны шесть, — а «алгоритм решения задач такого типа» крепится +не к `task`, а к `fix` и `research`. Отдельного типа `idea` тоже не осталось: +он значил не род работы, а **состояние незаполненности**, и это состояние +теперь называется честно — `research` без раздела «Вопрос». **Цель — возможность приложения**, а не тема работ: она отвечает на вопрос «что приложение будет уметь», её «Завершение» — наблюдаемый признак того, что уже @@ -37,26 +47,23 @@ av-dev, и подгоняется под него проект. Имена вн сделать». Отсюда роадмап и есть состояние проекта: достигнутая цель не исчезает, а переезжает строкой с датой в секцию достигнутого. -**Цель обязательна не у всякой задачи.** Новая возможность (`kind:feature`) без -цели не бывает — цель и есть её содержание. Починка, техдолг и разведка служат +**Цель обязательна не у всякой задачи.** Новая возможность (`feature`) без цели +не бывает — цель и есть её содержание. Починка, техдолг и разведка служат работоспособности, а не направлению, и живут без цели законно; в набор спринта они входят помимо его цели. -**Род работы — вторая ось, и она отвечает на другой вопрос.** Тип записи говорит, -что это за запись; род (`feature` | `fix` | `chore` | `research`, тегом -`kind:<род>`) — какого рода работа. Одним значением на два вопроса не ответить: -идея бывает *про* функцию, эпик *и есть* функция. Словарь закрыт — открытый -разъедется на синонимах, и отбор по роду перестанет отвечать. +**Тип определяет схему записи**: какие разделы тела обязательны, какие +допустимы, нужна ли цель, берётся ли запись в спринт. Схема — TYPE_SCHEMA; +проза с алгоритмом работы над каждым типом — `references/task-<тип>.md`. Использование: tasks.py init [--dir DIR] [--sections …] [--items …] [--backlog …] … tasks.py check [--dir DIR] [--fix] tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag a,b] - [--goal S] [--kind K] [--index backlog|sprint|roadmap|all] - [--questions] - tasks.py add --slug S --title T [--type goal|idea] [--section S] - [--goal G] [--kind K] [--why H] [--reason R] [--tag a,b] [--dir DIR] - tasks.py edit S [--title T] [--why H] [--type T] [--goal G] [--kind K] + [--goal S] [--index backlog|sprint|roadmap|all] [--questions] + tasks.py add --slug S --title T --type goal|feature|fix|chore|research + [--section S] [--goal G] [--why H] [--reason R] [--tag a,b] [--dir DIR] + tasks.py edit S [--title T] [--why H] [--type T] [--goal G] [--add-tag a,b] [--rm-tag c,d] [--section S] [--dir DIR] tasks.py move S --section S [--reason R] [--after S | --first] [--dir DIR] tasks.py close S (--reason R | --implemented) [--dir DIR] @@ -120,6 +127,11 @@ DEFAULTS = { "criteria_heading": "Критерии приёмки", "surface_heading": "Затрагивает", "questions_heading": "Вопросы", + "completion_heading": "Завершение", + "repro_heading": "Воспроизведение", + "question_heading": "Вопрос", + "answer_heading": "Куда ляжет ответ", + "scope_heading": "Рамки", "oracle_word": "оракул", } @@ -167,7 +179,13 @@ WHY_KEYS = ("зачем", "why", "хук", "hook") # перечислены по месту), а поиску поля, отбившегося от блока: сверять с # закрытым списком — единственный способ не спутать поле меты со строкой тела # вида `- **Важно:** …`. -META_KEYS = {"секция", "section", "теги", "tags", *WHY_KEYS} +TYPE_KEYS = ("тип", "type") +# «Секция» — прежнее имя поля, оставшееся у цели: она указывает на часть +# роадмапа, а это состояние очереди, а не полка домена. У всех прочих типов +# поле называется «Категория». Разбор принимает оба ключа у любого типа, чтобы +# файлы переезжали сами; `check` называет несовпадение дрейфом, `--fix` правит. +PLACE_KEYS = ("категория", "category", "секция", "section") +META_KEYS = {*TYPE_KEYS, *PLACE_KEYS, "теги", "tags", *WHY_KEYS} def meta_span(lines: list[str]) -> tuple[int, int] | None: @@ -217,23 +235,56 @@ BULLET = re.compile(r"^[-*]\s+(.*)$") REJECTED_ENTRY = re.compile(r"^- \d{4}-\d{2}-\d{2} `[a-z0-9-]+` — .+") GOAL = "goal" -TYPES = ("goal", "idea") # непустые типы-ключевые слова, префикс [..] в H1 -PLAIN_TYPE = "task" # обычная задача — без префикса -TAKEABLE = (PLAIN_TYPE,) # что вообще можно взять в спринт +RESEARCH = "research" +# Тип — единственная ось и **закрытый словарь**. Открытый разъедется на +# синонимах (`bug`, `bugfix`, `fix`, `defect`), и отбор по типу перестанет +# отвечать на свой единственный вопрос. Ни один тип не подходит — это сигнал, +# что в записи их два и её надо разделить. +TYPES = ("goal", "feature", "fix", "chore", RESEARCH) +# Эмодзи **производна от типа**, а не второй его дом: её ставит `add` и чинит +# `check --fix`. Живёт в H1 потому, что строка индекса копирует заголовок +# дословно, — так тип виден там, где решают «брать или не брать», и инвариант +# «заголовок в индексе дословно» остаётся нетронутым. +TYPE_EMOJI = {"goal": "🎯", "feature": "✨", "fix": "🐞", + "chore": "🧹", RESEARCH: "🔬"} +EMOJI_TYPE = {v: k for k, v in TYPE_EMOJI.items()} +TAKEABLE = ("feature", "fix", "chore", RESEARCH) # что берётся в спринт +# Заголовок в форме действия требуется там, где исход работы — изменение +# системы. У цели он называет возможность, у разведки — предмет: её исход +# знание, и заголовок-действие обещал бы решённость, которой ещё нет. +ACTION_TYPES = ("feature", "fix", "chore") QUESTION_TAG = "question" GOAL_TAG = "goal:" SPRINT_TAG = "sprint:" -# Род работы — вторая ось типа. Первая («тип записи»: goal/idea/epic/task) -# отвечает «что это за запись», вторая — «какого рода работа». Смешивать их в -# одном префиксе нельзя: идея *про* функцию, эпик *и есть* функция, и одно -# значение на два вопроса не отвечает. Дом рода — тег, потому что теги здесь и -# есть единственный механизм разметки, а `list --tag` уже умеет отбирать. -KIND_TAG = "kind:" -KINDS = ("feature", "fix", "chore", "research") # Цель обязательна только у новой возможности: цель и есть возможность. # Починка, техдолг и разведка служат работоспособности, а не направлению — -# придуманная им цель это то же враньё, от которого спасает род работы. +# придуманная им цель это то же враньё, от которого спасает тип. NEEDS_GOAL = ("feature",) + +# Схема тела на тип: какие разделы обязательны, какие ещё допустимы. Значения — +# **ключи конфига**, а не сами заголовки: имена заголовков проект настраивает, +# и схема, хранящая текст, разошлась бы с ними на первой же настройке. +# +# Обязательность проверяется там, где по ней принимают решение, — при взятии в +# спринт (и у задачи, уже стоящей в наборе). Раздел не из схемы даёт +# **замечание**, а не ошибку: свой раздел в теле — законная вольность проекта, +# а вот раздел, которого тип не предполагает, чаще всего означает, что тип +# проставлен не тот. +TYPE_SCHEMA = { + "goal": {"required": ("completion_heading",), "allowed": ()}, + "feature": {"required": ("surface_heading", "criteria_heading"), + "allowed": ("scope_heading", "questions_heading")}, + "fix": {"required": ("repro_heading", "surface_heading", "criteria_heading"), + "allowed": ("scope_heading", "questions_heading")}, + "chore": {"required": ("surface_heading", "criteria_heading"), + "allowed": ("scope_heading", "questions_heading")}, + RESEARCH: {"required": ("question_heading", "answer_heading"), + "allowed": ("scope_heading", "questions_heading")}, +} +# Прежние дома типа. Больше не пишутся; читаются, чтобы файлы переезжали сами: +# `check --fix` переносит значение в поле «Тип», снимает тег и ставит эмодзи. +LEGACY_KIND_TAG = "kind:" +LEGACY_IDEA = "idea" # тип `[idea]` упразднён: это research без «Вопроса» DECOMPOSED_TAG = "decomposed" # цель разложена на задачи (см. «Статус цели») STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check CRITERIA_MIN, CRITERIA_MAX = 2, 5 # сколько утверждений в критериях приёмки @@ -288,16 +339,16 @@ def bad_tags(raw: str | None) -> str | None: return None -def bad_kind(kind: str | None) -> str | None: - """Род работы — закрытый словарь: открытый разъедется на синонимах. +def bad_type(rtype: str | None) -> str | None: + """Тип — закрытый словарь: открытый разъедется на синонимах. Пять человек заведут `bug`, `bugfix`, `fix`, `defect` и `починка`, и отбор - по роду перестанет отвечать на свой единственный вопрос. + по типу перестанет отвечать на свой единственный вопрос. """ - if kind is None: + if rtype is None: return None - if kind.strip().lower() not in KINDS: - return (f"род работы «{kind}» не из словаря: {', '.join(KINDS)}." + if rtype.strip().lower() not in TYPES: + return (f"тип «{rtype}» не из словаря: {', '.join(TYPES)}." f" Не подходит ни один — это сигнал, что задача не одна") return None @@ -597,6 +648,36 @@ def spaced_sections(lines: list[str]) -> list[str]: return out +def raw_last(lines: list[str], raw: set[str]) -> list[str]: + """Строки индекса, у которых сырьё снесено в конец своей секции. + + Сырьё (`research` без раздела «Вопрос») в спринт не берётся, и стоя между + берущимися оно каждый раз требует открыть файл, чтобы это понять. Порядка + «по важности» в беклоге по-прежнему нет: этот порядок **производен от + типа**, а не назначен человеком, — потому его и можно проверять машиной. + + Переставляются только сами строки-пункты, по своим же позициям: проза + внутри секции, отбивка и заголовки остаются на месте. + """ + out = list(lines) + heads = [i for i, line in enumerate(lines) if SECTION.match(line)] + for k, start in enumerate(heads): + end = heads[k + 1] if k + 1 < len(heads) else len(lines) + pos = [i for i in range(start + 1, end) if INDEX_ENTRY.match(lines[i])] + if not pos: + continue + + def is_raw(line: str) -> bool: + m = INDEX_ENTRY.match(line) + return m is not None and Path(m.group(2)).name in raw + + vals = [lines[i] for i in pos] + for i, v in zip(pos, [v for v in vals if not is_raw(v)] + + [v for v in vals if is_raw(v)], strict=True): + out[i] = v + return out + + def index_lint(lines: list[str], label: str) -> list[str]: """Структурные дефекты индекса, которых схлопнутый dict не видит: битые строки-пункты, дубли на один файл, задачи до первой секции.""" @@ -654,33 +735,65 @@ def body_sections(text: str) -> dict[str, str]: return {k: "\n".join(v).strip() for k, v in out.items()} +def title_parts(title: str) -> tuple[str, str]: + """(тип, выведенный из заголовка; заголовок без эмодзи и префикса). + + Читаются обе формы: текущая (эмодзи) и прежняя (`[goal]`/`[idea]`). Тип из + заголовка — **запасной источник**: дом типа поле меты, а эмодзи от него + производна. Нужен он там, где меты ещё нет: у файлов, не переехавших на + поле, и у текста, восстановленного из git. + """ + bare = title.strip() + if (m := TYPE_PREFIX.match(bare)): + return m.group(1).strip().lower(), m.group(2).strip() + head = bare.split(maxsplit=1) + if head and head[0] in EMOJI_TYPE: + return EMOJI_TYPE[head[0]], (head[1].strip() if len(head) > 1 else "") + return "", bare + + +def h1_of(rtype: str, bare: str) -> str: + """Заголовок H1 из типа и чистого текста: эмодзи производна от типа.""" + emoji = TYPE_EMOJI.get(rtype) + return f"{emoji} {bare}" if emoji else bare + + def parse_task(path: Path) -> dict: text = path.read_text(encoding="utf-8") lines = text.splitlines() title = lines[0].removeprefix("#").strip() if lines and lines[0].startswith("#") else "" - rtype, bare = PLAIN_TYPE, title - if (m := TYPE_PREFIX.match(title)): - rtype, bare = m.group(1).strip().lower(), m.group(2).strip() + head_type, bare = title_parts(title) # Мета — блок под заголовком (task-format.md). Порядок полей свободный: - # секция распознаётся, где бы она ни стояла. - section, section_raw, reason, why, tags, legacy = "", "", "", "", [], False + # поле распознаётся, где бы оно ни стояло. Пишется тип первым. + section, section_raw, place_key = "", "", "" + meta_type, 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"): + if key in TYPE_KEYS: + meta_type = value.strip().lower() + elif key in PLACE_KEYS: section, _, reason = (p.strip() for p in value.partition("—")) section = section.rstrip(".,") section_raw, section = section, section.lower() + place_key = key elif key in WHY_KEYS: why = value elif key in ("теги", "tags"): tags = [t.strip().lower() for t in value.split(",") if t.strip()] goal = next((t[len(GOAL_TAG):] for t in tags if t.startswith(GOAL_TAG)), "") - 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, + legacy_kind = next((t[len(LEGACY_KIND_TAG):] for t in tags + if t.startswith(LEGACY_KIND_TAG)), "") + # Дом типа — поле меты. Прежние дома читаются по убыванию определённости: + # тег рода работы называл его прямо, заголовок — только у цели и идеи. + rtype = meta_type or legacy_kind or head_type + if rtype == LEGACY_IDEA: + rtype = RESEARCH + return {"title": title, "bare": bare, "type": rtype, "meta_type": meta_type, + "head_type": head_type, "legacy_kind": legacy_kind, + "section": section, "section_raw": section_raw, "place_key": place_key, + "reason": reason, "why": why, "tags": tags, "goal": goal, "path": path, "stray_meta": stray_meta(lines, span), "legacy_meta": legacy, "text": text, "body": body_sections(text)} @@ -795,6 +908,82 @@ def surface_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]: return [], [] +# --- Схема тела: тип решает, каких разделов запись обязана иметь --- + +# Зачем нужен раздел — по ключу конфига. Текст идёт в отказ: «нет раздела X» +# без причины читается как придирка формы, а причина у каждого своя и +# проектная. +SCHEMA_WHY = { + "completion_heading": "по чему видно, что цель достигнута; на строки этого" + " раздела ссылаются её задачи", + "repro_heading": "расхождение, которое не воспроизводится, — это research," + " а не fix: чинить нечего, пока непонятно, что ломается", + "question_heading": "без вопроса это не разведка, а сырьё — в спринт не берётся", + "answer_heading": "приёмка разведки — записанный ответ, и место ему" + " (docs/research/, ADR, тело задачи) называется заранее," + " иначе ответ останется в переписке", +} + + +def place_key_of(rtype: str) -> str: + """Имя поля меты, называющего, где запись числится. + + У цели это «Секция» — часть роадмапа, то есть состояние очереди. У всех + прочих «Категория» — полка домена, в которую задача возвращается из + спринта. Одно имя на два смысла и было конфляцией: секция роадмапа не + категория, а категория беклога не состояние очереди. + """ + return "Секция" if rtype == GOAL else "Категория" + + +def raw_research(lay: Layout, task: dict) -> bool: + """Сырьё: разведка, у которой ещё нет вопроса. + + Прежде это был отдельный тип `[idea]`. Отдельным типом «ещё не описано» + быть не может — это **состояние заполненности**, и различает его раздел, а + не словарь. Отсюда и место сырья: конец секции, чтобы оно не стояло между + тем, что берут. + """ + return (task["type"] == RESEARCH + and not task["body"].get(lay.cfg["question_heading"].lower())) + + +def schema_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]: + """Отказы и замечания по схеме тела: разделы, которых требует тип. + + Обязательный раздел даёт отказ, лишний — замечание. Разница намеренная: + свой раздел в теле законная вольность проекта, а раздел, которого тип не + предполагает («Воспроизведение» у chore), чаще всего означает, что тип + проставлен не тот, — и об этом стоит сказать, не запрещая. + """ + name, rtype = task["path"].name, task["type"] + schema = TYPE_SCHEMA.get(rtype) + if schema is None: + return ([f"{name}: тип «{rtype or '—'}» вне словаря" + f" ({', '.join(TYPES)}) — какие разделы обязательны, неизвестно"], []) + errors: list[str] = [] + notes: list[str] = [] + for key in schema["required"]: + if key == "criteria_heading": + e, n = criteria_verdict(lay, task) + elif key == "surface_heading": + e, n = surface_verdict(lay, task) + else: + heading = lay.cfg[key] + e = ([] if task["body"].get(heading.lower()) + else [f"{name}: нет раздела «{heading}» — {SCHEMA_WHY[key]}"]) + n = [] + errors += e + notes += n + known = {lay.cfg[k].lower() for k in (*schema["required"], *schema["allowed"])} + extra = sorted(h for h, v in task["body"].items() if h not in known and v) + if extra: + notes.append(f"{name}: разделы не из схемы типа «{rtype}»:" + f" {', '.join(extra)} — либо тип проставлен не тот," + f" либо это осознанный раздел проекта") + return errors, notes + + def questions_open(lay: Layout, task: dict) -> bool: """Открытый вопрос — это **непустой раздел**, а не тег. @@ -879,6 +1068,7 @@ def check(lay: Layout, fix: bool = False) -> int: known = {k: {s.lower() for s in sections[k]} for k in lay.indexes} goal_slugs = {p[:-3] for p, t in tasks.items() if t["type"] == GOAL} + raw_names = {n for n, t in tasks.items() if raw_research(lay, t)} goal_of_sprint, _ = sprint_goal(lay) for s in sections["backlog"]: @@ -895,9 +1085,38 @@ def check(lay: Layout, fix: bool = False) -> int: errors.append(f"{name}: слаг не kebab-case латиницей") if not task["title"]: errors.append(f"{name}: нет заголовка H1") - if task["type"] not in TYPES and task["type"] != PLAIN_TYPE: - notes.append(f"{name}: тип «{task['type']}» вне словаря" - f" ({'/'.join(TYPES)} или без префикса)") + + # 0. Тип — единственная ось, и от него зависит всё остальное: схема + # тела, дом строки, имя поля меты, право на взятие в спринт. + # Пропуск — замечание: записи, заведённые до появления типа, + # законны, и переоформлять беклог «заодно» здесь не просят. + # Обязательным тип становится там, где по нему принимают решение. + if not task["type"]: + notes.append(f"{name}: тип не назван — `tasks.py edit {name[:-3]}" + f" --type {'|'.join(TYPES)}`; в спринт без него не возьмут") + elif task["type"] not in TYPES: + errors.append(f"{name}: тип «{task['type']}» вне словаря" + f" ({', '.join(TYPES)}) — словарь закрыт, иначе отбор" + f" по типу разъедется на синонимах") + elif not task["meta_type"]: + was = (f"тегом {LEGACY_KIND_TAG}{task['legacy_kind']}" + if task["legacy_kind"] else "префиксом заголовка") + errors.append(f"{name}: тип задан прежним домом ({was}) — дом типа" + f" поле **Тип:** первой строкой меты; перенесёт" + f" `check --fix`") + elif task["legacy_kind"]: + errors.append(f"{name}: тег {LEGACY_KIND_TAG}{task['legacy_kind']} рядом с" + f" полем **Тип:** — род работы стал типом," + f" тег снимает `check --fix`") + + # 0а. Эмодзи производна от типа и живёт в H1: строка индекса копирует + # заголовок дословно, и тип виден там, где решают «брать или нет». + if task["type"] in TYPES and task["title"]: + want_h1 = h1_of(task["type"], task["bare"]) + if task["title"] != want_h1: + errors.append(f"{name}: заголовок не несёт эмодзи типа" + f" «{task['type']}» — надо «{want_h1}»;" + f" поставит `check --fix`") # 1. Задача живёт ровно в одном индексе за раз. allowed = {home} | ({"sprint"} if home == "backlog" else set()) @@ -925,8 +1144,16 @@ def check(lay: Layout, fix: bool = False) -> int: f" пустой строкой, и всё, что ниже разрыва, потеряно." f" Убери пустую строку внутри блока; `--fix` этого не" f" делает: какое из двух значений верное, знает человек") + want_key = place_key_of(task["type"]) + if task["type"] in TYPES and task["place_key"] \ + and task["place_key"] != want_key.lower(): + errors.append(f"{name}: поле меты названо «{task['place_key']}», а у типа" + f" «{task['type']}» оно «{want_key}»: у цели это часть" + f" роадмапа (состояние очереди), у задачи — полка домена," + f" в которую она возвращается из спринта." + f" Переименует `check --fix`") if not task["section"]: - errors.append(f"{name}: нет поля **Секция:** в мета-блоке") + errors.append(f"{name}: нет поля **{want_key}:** в мета-блоке") elif task["section"] not in known[home]: errors.append(f"{name}: секция «{task['section']}» не совпадает ни с одной" f" секцией {label[home]} ({', '.join(sections[home])})") @@ -957,34 +1184,23 @@ def check(lay: Layout, fix: bool = False) -> int: # задач цели выводится `list --goal`, а не хранится. if task["type"] != GOAL: if not task["goal"]: - if task["type"] == "idea": - notes.append(f"{name}: идея без цели — цель проставляется," - f" когда идея становится задачей") - elif task["kind"] in NEEDS_GOAL: - errors.append(f"{name}: род «{task['kind']}» без тега" + if task["type"] in NEEDS_GOAL: + errors.append(f"{name}: тип «{task['type']}» без тега" f" {GOAL_TAG}<слаг> — новая возможность и есть" f" содержание цели. Либо цель заводится, либо" - f" это не {task['kind']}") - # Операционная задача (fix, chore, research) живёт без цели - # законно: она служит работоспособности, а не направлению. + f" это не {task['type']}") + # fix, chore и research живут без цели законно: они служат + # работоспособности, а не направлению. elif task["goal"] not in goal_slugs: errors.append(f"{name}: тег {GOAL_TAG}{task['goal']} указывает на цель," f" которой нет в {lay.cfg['items']}/") - # 3а. Род работы. Замечание, а не отказ: беклог, заведённый до появления - # рода, законен, и переоформлять его «заодно» здесь не просят. - # Обязательным род становится там, где по нему принимают решение, — - # при взятии в спринт. - if task["kind"] and task["kind"] not in KINDS: - errors.append(f"{name}: род работы «{task['kind']}» не из словаря" - f" ({', '.join(KINDS)}) — словарь закрыт, иначе отбор" - f" по роду разъедется на синонимах") - elif task["type"] == GOAL and task["kind"]: - errors.append(f"{name}: у цели род работы «{task['kind']}» —" - f" цель это направление, а не работа; род несут её задачи") - elif task["type"] in TAKEABLE and not task["kind"]: - notes.append(f"{name}: без рода работы — `tasks.py edit {name[:-3]}" - f" --kind {'|'.join(KINDS)}`; в спринт без него не возьмут") + # 3а. Схема тела вне спринта — только замечанием: обязательным раздел + # становится там, где по нему принимают решение (`sprint take`). + # Лишний раздел говорит о неверном типе, и сказать об этом стоит + # сразу, не дожидаясь набора. + if task["type"] in TYPES: + notes += schema_verdict(lay, task)[1] # 4. Спринт: задача с открытым вопросом в набор не берётся, и судит об # этом раздел, а не тег. @@ -996,19 +1212,18 @@ def check(lay: Layout, fix: bool = False) -> int: elif QUESTION_TAG in task["tags"]: errors.append(f"{name}: в спринте с тегом «{QUESTION_TAG}» —" f" либо вопрос открыт и задача выходит, либо тег снимается") - if task["type"] not in TAKEABLE: + if task["type"] and task["type"] not in TAKEABLE: errors.append(f"{name}: в спринте тип «{task['type']}»" - f" — берутся только задачи") + f" — цель не берут вовсе, её берут её задачи") if goal_of_sprint and task["goal"] and task["goal"] != goal_of_sprint: errors.append(f"{name}: цель задачи «{task['goal']}» не цель спринта" f" «{goal_of_sprint}» — набор служит одной цели") - if not task["kind"]: - errors.append(f"{name}: в спринте без рода работы" - f" (`edit {name[:-3]} --kind …`)") - for verdict in (criteria_verdict, surface_verdict): - e, n = verdict(lay, task) - errors += [f"{x} (в спринте)" for x in e] - notes += n + if not task["type"]: + errors.append(f"{name}: в спринте без типа" + f" (`edit {name[:-3]} --type {'|'.join(TAKEABLE)}`)") + # Замечания схемы уже собраны выше — здесь берутся только отказы, + # иначе один и тот же лишний раздел печатался бы дважды. + errors += [f"{x} (в спринте)" for x in schema_verdict(lay, task)[0]] # 5. Тег «question» производен от раздела: раздел — факт, тег — метка. has_q_section = questions_open(lay, task) @@ -1043,6 +1258,11 @@ def check(lay: Layout, fix: bool = False) -> int: errors += index_lint(lines, label[kind]) if kind == "roadmap": errors += roadmap_lint(lines, label[kind]) + if kind == "backlog" and raw_last(lines, raw_names) != lines: + errors.append(f"{label[kind]}: сырьё (`{RESEARCH}` без раздела" + f" «{lay.cfg['question_heading']}») стоит не в конце своей" + f" секции — его не берут, и между берущимся оно требует" + f" открыть файл, чтобы это понять; переставит `check --fix`") if goal_of_sprint and goal_of_sprint + ".md" not in tasks: errors.append(f"{label['sprint']}: цель «{goal_of_sprint}» не найдена" @@ -1095,6 +1315,28 @@ def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None: if by_section: print(" беклог: " + ", ".join(f"{s} {by_section[s.lower()]}" for s in sections["backlog"])) + by_type: dict[str, int] = {} + for t in tasks.values(): + by_type[t["type"] or "без типа"] = by_type.get(t["type"] or "без типа", 0) + 1 + if by_type: + raw = sum(1 for t in tasks.values() if raw_research(lay, t)) + print(" типы: " + ", ".join(f"{k} {by_type[k]}" for k in (*TYPES, "без типа") + if k in by_type) + + (f" (сырьём, без «{lay.cfg['question_heading']}», {raw})" if raw else "")) + + # Готовность к взятию — та же проверка, что откажет `sprint take`. Число, а + # не перечень: оно отвечает на «есть ли из чего собрать спринт», и когда + # ответ «нет», разбирать надо не список, а порцию переоценки. + if backlog: + ready = [t for t in backlog + if t["type"] in TAKEABLE and not questions_open(lay, t) + and not schema_verdict(lay, t)[0] + and not (t["type"] in NEEDS_GOAL and not t["goal"])] + print(f" готово к взятию: {len(ready)} из {len(backlog)}" + + ("" if len(ready) == len(backlog) + else " — прочим не хватает разделов своего типа, цели или ждут" + " ответа на вопрос")) + goal_slug, _ = sprint_goal(lay) if goal_slug: print(f" спринт: цель «{goal_slug}», слаг «{sprint_slug(lay) or '—'}»," @@ -1111,7 +1353,7 @@ def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None: # проверка эвристическая, а беклог, заведённый до правила, переоформляют не # «заодно»: десятки одинаковых замечаний научили бы пропускать весь блок. flat = sorted(n[:-3] for n, t in tasks.items() - if t["type"] in TAKEABLE and not action_title(t["bare"])) + if t["type"] in ACTION_TYPES and not action_title(t["bare"])) if flat: print(f" заголовков не в форме действия: {len(flat)}" f" ({', '.join(flat[:5])}{', …' if len(flat) > 5 else ''})" @@ -1170,7 +1412,7 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int: continue if a.goal and t["goal"] != a.goal.lower(): continue - if a.kind and t["kind"] != a.kind.lower(): + if a.raw and not raw_research(lay, t): continue if a.questions and not questions_open(lay, t): continue @@ -1186,12 +1428,13 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int: for t in rows: touched = f"{t.get('touched', ''):<11}" if a.stale else "" - rtype = "" if t["type"] == PLAIN_TYPE else f"[{t['type']}] " - kind = "" if a.kind else f"{t['kind']:<9}" + # Тип не печатается, когда по нему уже отобрали: колонка, одинаковая во + # всех строках, только съедает ширину под заголовок. + rtype = "" if a.type else f"{t['type'] or '—':<9}" goal = f" →{t['goal']}" if t["goal"] and not a.goal else "" - flag = " ?" if questions_open(lay, t) else " " - print(f"{touched}{t['place']:<8}{t['section']:<12}{kind}{flag} " - f"{t['path'].stem:<44} {rtype}{t['bare']}{goal}") + flag = " ?" if questions_open(lay, t) else ("~" if raw_research(lay, t) else " ") + print(f"{touched}{t['place']:<8}{t['section']:<12}{rtype}{flag:<2}" + f"{t['path'].stem:<44} {t['bare']}{goal}") print(f"\nвсего: {len(rows)}") # Пустой ответ обязан объясняться: молчаливый ноль читается как «таких @@ -1210,8 +1453,15 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int: # --- Мутации: правят файл и индексы заодно, рассогласовать их вручную нельзя --- -def build_meta(section: str, reason: str, why: str, tags: list[str]) -> str: - out = [f"- **Секция:** {section}" + (f" — {reason}" if reason else "")] +def build_meta(rtype: str, section: str, reason: str, why: str, tags: list[str]) -> str: + """Мета-блок: тип первой строкой, дальше место, «зачем» и теги. + + Тип стоит первым не для красоты: он решает, что у записи вообще может быть + — какие разделы обязательны, нужна ли цель, берётся ли она в спринт, — и + читается раньше всего остального. + """ + out = [f"- **Тип:** {rtype}"] if rtype else [] + out.append(f"- **{place_key_of(rtype)}:** {section}" + (f" — {reason}" if reason else "")) if why: out.append(f"- **Зачем:** {why}") if tags: @@ -1360,11 +1610,13 @@ def insert_entry(lines: list[str], section: str, entry: str, def meta_rebuilt(lines: list[str], section: str | None = None, reason: str | None = None, - why: str | None = None, tags: list[str] | None = None) -> list[str] | None: + why: str | None = None, tags: list[str] | None = None, + rtype: str | None = None) -> list[str] | None: """Строки файла с пересобранным мета-блоком. None — меты нет. Пересобирается **весь блок**, а не правится по месту: заодно старая форма - (всё одной строкой через `·`) переезжает в новую. Нераспознанные поля + (всё одной строкой через `·`) переезжает в новую, а поле места получает имя + по типу — «Секция» у цели, «Категория» у прочих. Нераспознанные поля переносятся как есть, с их написанием ключа, и встают после известных — терять чужое поле нельзя, но и порядок ему диктовать незачем. @@ -1375,11 +1627,13 @@ def meta_rebuilt(lines: list[str], section: str | None = None, reason: str | Non if span is None: return None old = meta_fields(lines, span) - cur_section, cur_reason, cur_why, cur_tags, extra = "", "", "", "", [] + cur_section, cur_reason, cur_why, cur_tags, cur_type, extra = "", "", "", "", "", [] seen_section = False for key, value in old: low = key.lower() - if low in ("секция", "section"): + if low in TYPE_KEYS: + cur_type = value.strip().lower() + elif low in PLACE_KEYS: cur_section, _, cur_reason = (p.strip() for p in value.partition("—")) seen_section = True elif low in WHY_KEYS: @@ -1389,11 +1643,14 @@ def meta_rebuilt(lines: list[str], section: str | None = None, reason: str | Non else: extra.append(f"- **{key}:** {value}") if not seen_section: - return None # мета без секции сломана; `check` скажет это словами - out = [f"- **Секция:** {section if section is not None else cur_section}"] + return None # мета без места сломана; `check` скажет это словами + new_type = rtype if rtype is not None else cur_type + out = [f"- **Тип:** {new_type}"] if new_type else [] + out.append(f"- **{place_key_of(new_type)}:**" + f" {section if section is not None else cur_section}") rsn = reason if reason is not None else cur_reason if rsn: - out[0] += f" — {rsn}" + out[-1] += f" — {rsn}" new_why = why if why is not None else cur_why if new_why: out.append(f"- **Зачем:** {new_why}") @@ -1404,9 +1661,11 @@ def meta_rebuilt(lines: list[str], section: str | None = None, reason: str | Non def meta_updated(path: Path, section: str | None = None, reason: str | None = None, - why: str | None = None, tags: list[str] | None = None) -> str | None: + why: str | None = None, tags: list[str] | None = None, + rtype: 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) + out = meta_rebuilt(lines, section=section, reason=reason, why=why, tags=tags, + rtype=rtype) return None if out is None else "\n".join(out) + "\n" @@ -1415,38 +1674,69 @@ def entry_line(lay: Layout, title: str, slug: str, why: str) -> str: return f"- [{title}]({link})" + (f" — {why}" if why else "") -def body_template(kind: str, lay: Layout) -> str: - if kind == GOAL: - return ("\n\n" - "## Завершение\n\n" - "\n") - if kind == "idea": - return ("\n") - return ("\n\n" - f"## {lay.cfg['surface_heading']}\n\n" - "\n\n" - f"## {lay.cfg['criteria_heading']}\n\n" - f"\n\n" - "## Рамки\n\n" - "\n") +# Подсказка в шаблоне — по ключу конфига, один текст на раздел. Держать её +# рядом со схемой, а не расписывать шаблон на каждый тип: тип решает, какие +# разделы положить, а что писать в разделе, от типа не зависит. +SECTION_HINT = { + "completion_heading": "по чему видно, что цель достигнута; задачи здесь НЕ" + " перечисляются — перечень даёт" + " `tasks.py list --goal <слаг>`", + "surface_heading": "границы, которых изменение касается: эндпоинт или" + " команда, таблица и миграция, формат на диске, публичный" + " тип пакета, внешний сервис. Названы границы, а не то, как" + " они изменятся: план реализации живёт в предложении", + "criteria_heading": "проверяемые утверждения списком, у каждого назван оракул", + "repro_heading": "что сделать, чтобы расхождение проявилось, и что при этом" + " видно вместо ожидаемого. Не воспроизводится — это research," + " а не fix", + "question_heading": "вопрос, на который отвечает эта разведка, — одной фразой." + " Пока его нет, это сырьё: в спринт не берут", + "answer_heading": "куда ляжет ответ: docs/research/<тема>.md, ADR, тело этой" + " задачи. Приёмка разведки — записанный ответ, а не" + " изменённый код", + "scope_heading": "одна строка: чего касаться нельзя, что перезапускается, что" + " считается необратимым", +} + +BODY_LEAD = { + "goal": "зачем эта цель: какое состояние продукта она создаёт", + "feature": "задача в одной фразе: что станет наблюдаемо иначе", + "fix": "что расходится с заявленным — в одной фразе", + "chore": "что обслуживаем и что перестанет мешать; адресат здесь" + " разработчик, и это законно", + RESEARCH: "о чём разведка: что непонятно и почему это мешает решать", +} + + +def body_template(rtype: str, lay: Layout) -> str: + """Шаблон тела по схеме типа: обязательные разделы плюс «Рамки». + + Шаблон и проверка растут из одного TYPE_SCHEMA: разойтись им нельзя, иначе + `add` кладёт то, на чём `sprint take` потом откажет. + """ + schema = TYPE_SCHEMA.get(rtype, TYPE_SCHEMA["feature"]) + out = [f""] + keys = list(schema["required"]) + if "scope_heading" in schema["allowed"]: + keys.append("scope_heading") + for key in keys: + hint = SECTION_HINT[key] + if key == "criteria_heading": + hint = (f"{CRITERIA_MIN}–{CRITERIA_MAX} проверяемых утверждений списком," + f" у каждого назван {lay.cfg['oracle_word']}") + out.append(f"## {lay.cfg[key]}\n\n") + return "\n\n".join(out) + "\n" def cmd_add(lay: Layout, a: argparse.Namespace) -> int: for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_why(a.why), - bad_tags(a.tag), bad_reason(a.reason), bad_kind(a.kind), + bad_tags(a.tag), bad_reason(a.reason), bad_type(a.type), bad_slug(a.goal) if a.goal else None): if err: raise Usage(err) if not a.title.strip(): raise Usage("пустой заголовок") - rtype = (a.type or "").strip().lower() + rtype = a.type.strip().lower() path = lay.items / f"{a.slug}.md" if path.exists(): raise Usage(f"{path.name} уже существует — дедуп: допиши в него, а не заводи новый") @@ -1468,14 +1758,8 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int: tags = [t for t in tags if not t.startswith(GOAL_TAG)] + [f"{GOAL_TAG}{a.goal}"] if not (lay.items / f"{a.goal}.md").exists(): print(f" внимание: цели {a.goal}.md нет — заведи её (--type goal) или поправь тег") - if a.kind: - tags = [t for t in tags if not t.startswith(KIND_TAG)] + [f"{KIND_TAG}{a.kind.lower()}"] - elif not rtype: - print(f" без рода работы — проставь `tasks.py edit {a.slug} --kind" - f" {'|'.join(KINDS)}`: в спринт без него не возьмут") - if (rtype != GOAL and (a.kind or "") in NEEDS_GOAL - and not any(t.startswith(GOAL_TAG) for t in tags)): - print(f" род «{a.kind}» без цели: новая возможность и есть содержание" + if rtype in NEEDS_GOAL and not any(t.startswith(GOAL_TAG) for t in tags): + print(f" тип «{rtype}» без цели: новая возможность и есть содержание" f" цели — проставь `tasks.py edit {a.slug} --goal <слаг>`") # Урожай спринта метится сам: тег, который никто не ставит, не отбирает # первую порцию переоценки, а именно на ней держится правило «сперва урожай». @@ -1485,9 +1769,17 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int: if QUESTION_TAG in tags: print(f" тег «{QUESTION_TAG}»: не забудь раздел «{lay.cfg['questions_heading']}» в теле") - title_full = f"[{rtype}] {a.title}" if rtype else a.title - meta = build_meta(section, a.reason or "", a.why or "", tags) + title_full = h1_of(rtype, a.title) + meta = build_meta(rtype, section, a.reason or "", a.why or "", tags) insert_entry(lines, section, entry_line(lay, title_full, a.slug, a.why or "")) + if target == "backlog": + # Сырьё держится в конце секции сразу, а не до ближайшего `check --fix`: + # заводимая разведка вопроса ещё не несёт, а заводимая задача не должна + # вставать после неё. + raw = {n for n, t in tasks_of(lay).items() if raw_research(lay, t)} + if rtype == RESEARCH: + raw.add(f"{a.slug}.md") + lines[:] = raw_last(lines, raw) plan = Plan() plan.file(path, f"# {title_full}\n\n{meta}\n\n{body_template(rtype, lay)}") @@ -1522,13 +1814,13 @@ def warn_rejected(lay: Layout, slug: str, title: str) -> None: def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_why(a.why), - bad_tags(a.add_tag), bad_tags(a.rm_tag), bad_kind(a.kind), + bad_tags(a.add_tag), bad_tags(a.rm_tag), bad_type(a.type), bad_slug(a.goal) if a.goal else None): if err: raise Usage(err) - if all(v is None for v in (a.title, a.why, a.type, a.goal, a.kind, + if all(v is None for v in (a.title, a.why, a.type, a.goal, a.add_tag, a.rm_tag, a.section)): - raise Usage("нечего менять: дай --title, --why, --type, --goal, --kind," + raise Usage("нечего менять: дай --title, --why, --type, --goal," " --add-tag или --rm-tag") path = lay.items / f"{a.slug}.md" if not path.exists(): @@ -1541,19 +1833,18 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: if a.title is not None and not a.title.strip(): raise Usage("пустой заголовок") bare = a.title if a.title is not None else task["bare"] - kind = task["type"] if a.type is None else a.type.strip().lower() - old_home, new_home = home_index(task), home_index({"type": kind}) + rtype = task["type"] if a.type is None else a.type.strip().lower() + old_home, new_home = home_index(task), home_index({"type": rtype}) in_sprint = "sprint" in places - # Задача в спринте меняет тип только через выход из набора: [epic] или - # [idea] в наборе — состояние, которое check объявит ошибкой, а молчаливый - # успех оставит набор в нём. - if in_sprint and kind not in TAKEABLE: - raise Usage(f"{a.slug} в спринте, а тип «{kind}» в наборе не живёт:" + # Задача в спринте меняет тип только через выход из набора: цель в наборе — + # состояние, которое check объявит ошибкой, а молчаливый успех оставит + # набор в нём. + if in_sprint and rtype not in TAKEABLE: + raise Usage(f"{a.slug} в спринте, а тип «{rtype}» в наборе не живёт:" f" сперва выведи задачу — tasks.py sprint drop {a.slug} --reason …") - prefix = "" if kind in ("", PLAIN_TYPE) else f"[{kind}] " - h1 = f"{prefix}{bare}" + h1 = h1_of(rtype, bare) flines = path.read_text(encoding="utf-8").splitlines() if not flines or not flines[0].startswith("#"): @@ -1571,8 +1862,10 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: tags = [t for t in tags if not t.startswith(GOAL_TAG)] + [f"{GOAL_TAG}{a.goal}"] if not (lay.items / f"{a.goal}.md").exists(): print(f" внимание: цели {a.goal}.md нет — заведи её или поправь тег") - if a.kind is not None: - tags = [t for t in tags if not t.startswith(KIND_TAG)] + [f"{KIND_TAG}{a.kind.lower()}"] + # Род работы стал типом: тег снимается вместе с проставлением типа, чтобы + # второй дом не пережил правку и не разошёлся с первым. + if a.type is not None: + tags = [t for t in tags if not t.startswith(LEGACY_KIND_TAG)] why = task["why"] if a.why is None else a.why section = task["section"] @@ -1595,33 +1888,47 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: f" задай `--section <из перечисленных>`") section = section_name.lower() + # Тип передаётся всегда, а не только при `--type`: у файла, не переехавшего + # на поле, он выведен из прежнего дома, и без него пересборка меты назвала + # бы поле места по умолчанию — «Категория» вместо «Секции» у цели. new_text = meta_updated(path, section=section if section 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, + rtype=rtype or None) if new_text is None: - raise Usage(f"{a.slug}.md без поля **Секция:** в мете — прогони check и почини") + raise Usage(f"{a.slug}.md без поля **{place_key_of(rtype)}:** в мете —" + f" прогони check и почини") tlines = new_text.splitlines() tlines[0] = f"# {h1}" new_text = "\n".join(tlines) + "\n" + # Место сырья — конец секции, и оно производно от типа: смена типа обязана + # переставить строку сразу, иначе индекс уезжает в дрейф на ровном месте. + raw_now = {n for n, t in tasks_of(lay).items() if raw_research(lay, t)} + raw_now.discard(f"{a.slug}.md") + if rtype == RESEARCH and not task["body"].get(lay.cfg["question_heading"].lower()): + raw_now.add(f"{a.slug}.md") + + def ordered(kind_index: str, lines: list[str]) -> list[str]: + return raw_last(lines, raw_now) if kind_index == "backlog" else lines + plan = Plan() plan.file(path, new_text) if old_home != new_home: old_lines, ei = places[old_home] old_lines.pop(ei) # строка в новом индексе собирается заново - plan.index(lay, old_home, old_lines) + plan.index(lay, old_home, ordered(old_home, old_lines)) target_lines = read_lines(lay.index(new_home)) insert_entry(target_lines, section, entry_line(lay, h1, a.slug, why)) - plan.index(lay, new_home, target_lines) + plan.index(lay, new_home, ordered(new_home, target_lines)) else: for kind_index, (lines, ei) in places.items(): lines[ei] = entry_line(lay, h1, a.slug, why) - plan.index(lay, kind_index, lines) + plan.index(lay, kind_index, ordered(kind_index, lines)) plan.commit() changed = [n for n, v in (("заголовок", a.title), ("зачем", a.why), ("тип", a.type), - ("цель", a.goal), ("род работы", a.kind), - ("теги", a.add_tag or a.rm_tag)) + ("цель", a.goal), ("теги", a.add_tag or a.rm_tag)) if v is not None] print(f"{a.slug}: обновлено ({', '.join(changed)})") if len(places) > 1: @@ -1659,14 +1966,20 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int: if hi is None: avail = ", ".join(n for _, n in section_headers(lines)) raise Usage(f"нет секции «{a.section}» в {lay.name(kind_index)} (есть: {avail})") - new_text = meta_updated(path, section=section, reason=a.reason) + task = parse_task(path) + new_text = meta_updated(path, section=section, reason=a.reason, + rtype=task["type"] or None) if new_text is None: - raise Usage(f"{a.slug}.md без поля **Секция:** в мете — прогони check и почини") + raise Usage(f"{a.slug}.md без поля **{place_key_of(task['type'])}:** в мете —" + f" прогони check и почини") entry = lines.pop(ei) try: insert_entry(lines, section, entry, a.after, a.first) except KeyError as e: raise Usage(f"--after {a.after}: такой строки в секции «{section}» нет") from e + if kind_index == "backlog" and not (a.after or a.first): + lines[:] = raw_last(lines, {n for n, t in tasks_of(lay).items() + if raw_research(lay, t)}) plan = Plan() plan.file(path, new_text) @@ -1810,12 +2123,10 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: f" Заведи заново: tasks.py add --slug {a.slug} --title …") task_lines = text.splitlines() title = task_lines[0].removeprefix("#").strip() if task_lines else a.slug - kind = PLAIN_TYPE - if (m := TYPE_PREFIX.match(title)): - kind = m.group(1).strip().lower() + rtype = parse_task_text(text, path)["type"] if a.reason: - upd = meta_updated_text(text, reason=a.reason) + upd = meta_updated_text(text, reason=a.reason, rtype=rtype or None) if upd is None: print(" внимание: меты нет, причина возврата не записана в файл") else: @@ -1825,7 +2136,7 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: plan.file(path, text) goal_of_sprint, _ = sprint_goal(lay) - target = home_index({"type": kind}) + target = home_index({"type": rtype}) section = tmp["section"] if target == "backlog" and goal_of_sprint and tmp["goal"] == goal_of_sprint: target, section = "sprint", lay.cfg["sprint_section"] @@ -1833,7 +2144,7 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: # Строка достигнутого снимается ДО вставки и на том же списке: иначе вторая # правка читает индекс с диска, где первой ещё нет, и затирает её. unachieved: list[str] = [] - if kind == GOAL: + if rtype == GOAL: kept = [] for line in lines: if REJECTED_ENTRY.match(line) and f"`{a.slug}`" in line: @@ -1888,9 +2199,9 @@ def parse_task_text(text: str, path: Path) -> dict: tmp.unlink(missing_ok=True) -def meta_updated_text(text: str, reason: str) -> str | None: +def meta_updated_text(text: str, reason: str, rtype: str | None = None) -> str | None: """То же для текста, которого ещё нет на диске (возврат задачи из git).""" - out = meta_rebuilt(text.splitlines(), reason=reason) + out = meta_rebuilt(text.splitlines(), reason=reason, rtype=rtype) return None if out is None else "\n".join(out) + "\n" @@ -1908,7 +2219,8 @@ def cmd_sprint_start(lay: Layout, a: argparse.Namespace) -> int: raise Usage(f"цели {a.goal}.md нет в {lay.cfg['items']}/") goal = parse_task(gpath) if goal["type"] != GOAL: - raise Usage(f"{a.goal} не цель (тип «{goal['type']}») — спринт набирается под [goal]") + raise Usage(f"{a.goal} не цель (тип «{goal['type'] or '—'}») —" + f" спринт набирается под одну цель") date = a.date or datetime.date.today().isoformat() if not DATE_RE.fullmatch(date): raise Usage("дата в формате ГГГГ-ММ-ДД") @@ -1929,7 +2241,7 @@ def cmd_sprint_start(lay: Layout, a: argparse.Namespace) -> int: plan.commit() ready = [n[:-3] for n, t in tasks.items() if t["goal"] == a.goal and t["type"] in TAKEABLE - and not questions_open(lay, t)] + and not questions_open(lay, t) and not schema_verdict(lay, t)[0]] print(f"спринт начат: цель «{a.goal}», {date}, слаг «{slug}»") print(f" кандидатов под цель без открытых вопросов: {len(ready)}" + (f" ({', '.join(sorted(ready))})" if ready else "")) @@ -1955,14 +2267,19 @@ def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int: if not path.exists(): raise Usage(f"{slug}.md не найден в {lay.cfg['items']}/") t = parse_task(path) + if not t["type"]: + raise Usage(f"{slug}: тип не назван —" + f" `edit {slug} --type {'|'.join(TAKEABLE)}`." + f" Тип решает, каких разделов задача обязана иметь," + f" и без него проверять нечего") if t["type"] not in TAKEABLE: raise Usage(f"{slug}: тип «{t['type']}» в спринт не берётся —" - f" идея идёт на штурм, цель не берут вовсе") + f" цель не берут вовсе, берут её задачи") if t["goal"] and t["goal"] != goal_slug: raise Usage(f"{slug}: цель «{t['goal']}» не цель спринта «{goal_slug}» —" f" набор служит одной цели, даже если взять удобно") - if not t["goal"] and t["kind"] in NEEDS_GOAL: - raise Usage(f"{slug}: род «{t['kind']}» без цели —" + if not t["goal"] and t["type"] in NEEDS_GOAL: + raise Usage(f"{slug}: тип «{t['type']}» без цели —" f" новая возможность и есть содержание цели" f" (`edit {slug} --goal <слаг>`)") # Отказ по факту, а не по метке: непустой раздел «Вопросы» блокирует @@ -1977,16 +2294,10 @@ def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int: raise Usage(f"{slug}: тег «{QUESTION_TAG}» стоит, а раздела" f" «{lay.cfg['questions_heading']}» нет — либо вопрос записан не туда," f" либо тег пора снять: `edit {slug} --rm-tag {QUESTION_TAG}`") - if not t["kind"]: - raise Usage(f"{slug}: род работы не назван —" - f" `edit {slug} --kind {'|'.join(KINDS)}`." - f" По нему видно, что в наборе одни починки и ни одной" - f" функции, а это разговор про цель, а не про набор") - for verdict in (criteria_verdict, surface_verdict): - errs, notes = verdict(lay, t) - if errs: - raise Usage("; ".join(errs)) - warn += notes + errs, notes = schema_verdict(lay, t) + if errs: + raise Usage("; ".join(errs)) + warn += notes ei = find_entry_index(backlog_lines, slug) if ei is None: raise Usage(f"{slug}: строки в {lay.name('backlog')} нет" @@ -2032,9 +2343,11 @@ def cmd_sprint_drop(lay: Layout, a: argparse.Namespace) -> int: avail = ", ".join(n for _, n in section_headers(backlog_lines)) raise Usage(f"{slug}: секция «{t['section'] or '—'}» не найдена" 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, + rtype=t["type"] or None) if new_text is None: - raise Usage(f"{slug}.md без поля **Секция:** в мете — прогони check и почини") + raise Usage(f"{slug}.md без поля **{place_key_of(t['type'])}:** в мете —" + f" прогони check и почини") plan.file(path, new_text) insert_entry(backlog_lines, section, sprint_lines.pop(ei)) dropped.append(slug) @@ -2105,19 +2418,82 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: files: dict[Path, str] = {} dirty: set[str] = set() + def staged_lines(task: dict) -> list[str]: + """Текущий текст файла с учётом уже отложенных правок. + + Перечитать файл с диска посреди прохода значит стереть то, что положил + предыдущий шаг: шагов, правящих мету, четыре, и каждый видит свою + часть. + """ + staged = files.get(task["path"]) + return (staged.splitlines() if staged is not None + else task["path"].read_text(encoding="utf-8").splitlines()) + + def stage(task: dict, **kw) -> bool: + """Отложить пересборку меты. False — меты нет, чинить нечем.""" + rebuilt = meta_rebuilt(staged_lines(task), **kw) + if rebuilt is None: + return False + files[task["path"]] = "\n".join(rebuilt) + "\n" + return True + # 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" переписать нечего, чинится руками") + if not stage(task, rtype=task["type"] or None): + ambiguous.append(f"{name}: мета одной строкой и без поля места" + f" (**Категория:**/**Секция:**) — переписать нечего," + f" чинится руками") continue - files[task["path"]] = upd fixed.append(f"{name}: мета переписана списком") + # 0а. Тип переезжает в свой дом. Прежние дома — тег `kind:<род>` и префикс + # заголовка — читаются, но больше не пишутся; заодно заголовок получает + # эмодзи, а поле места — имя по типу. Тип, который не выводится + # ниоткуда, машина не угадывает: `feature` от `chore` отличает человек, + # и подставленное наугад значение врало бы ровно там, где по нему + # принимают решение. + for name, task in tasks.items(): + rtype = task["type"] + if not rtype: + ambiguous.append(f"{name}: тип не выводится — нет ни поля **Тип:**, ни" + f" тега {LEGACY_KIND_TAG}<род>, ни префикса заголовка." + f" Назови руками: `edit {name[:-3]} --type" + f" {'|'.join(TYPES)}`") + continue + if rtype not in TYPES: + ambiguous.append(f"{name}: тип «{rtype}» вне словаря" + f" ({', '.join(TYPES)}) — чем он заменяется," + f" решает человек") + continue + want_key = place_key_of(rtype).lower() + tags = [t for t in task["tags"] if not t.startswith(LEGACY_KIND_TAG)] + renamed = bool(task["place_key"]) and task["place_key"] != want_key + if task["meta_type"] != rtype or tags != task["tags"] or renamed: + if not stage(task, rtype=rtype, + tags=tags if tags != task["tags"] else None): + ambiguous.append(f"{name}: тип «{rtype}» переносить некуда —" + f" в файле нет мета-блока") + else: + what = [f"тип «{rtype}» в поле **Тип:**"] + if task["legacy_kind"]: + what.append(f"снят тег {LEGACY_KIND_TAG}{task['legacy_kind']}") + if renamed: + what.append(f"поле места → «{place_key_of(rtype)}»") + fixed.append(f"{name}: " + ", ".join(what)) + want_h1 = h1_of(rtype, task["bare"]) + if task["title"] and task["title"] != want_h1: + src = staged_lines(task) + if src and src[0].startswith("#"): + src[0] = f"# {want_h1}" + files[task["path"]] = "\n".join(src) + "\n" + # Шаг 2 сверяет строку индекса с этим полем — иначе индекс + # остался бы с прежним заголовком до следующего прогона. + task["title"] = want_h1 + fixed.append(f"{name}: заголовок получил эмодзи типа — {want_h1}") + # 1. Дубли строк на один файл — оставляем первую. for kind, lines in idx.items(): seen: set[str] = set() @@ -2146,12 +2522,10 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: continue idx_why = (m.group(3) or "").strip() if not task["why"] and idx_why: - upd = meta_updated(task["path"], why=idx_why) - if upd is None: + if not stage(task, why=idx_why): ambiguous.append(f"{name}: «зачем» только в {lay.name(kind)}," f" а в файле нет меты — перенести некуда") continue - files[task["path"]] = upd task["why"] = idx_why fixed.append(f"{name}: «зачем» перенесено из {lay.name(kind)} в мету файла") if m.group(1) != task["title"] or idx_why != task["why"]: @@ -2227,10 +2601,8 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: if task["type"] != GOAL or DECOMPOSED_TAG in task["tags"]: continue if any(t["goal"] == name[:-3] for t in tasks.values()): - upd = meta_updated(task["path"], tags=[*task["tags"], DECOMPOSED_TAG]) - if upd is None: + if not stage(task, tags=[*task["tags"], DECOMPOSED_TAG]): continue - files[task["path"]] = upd fixed.append(f"{name}: проставлен тег «{DECOMPOSED_TAG}» — у цели есть задачи") # 5. Форма индексов: канонический регистр секций роадмапа и отбивка после @@ -2255,6 +2627,14 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: fixed.append(f"{lay.name(kind)}: секции переставлены в канонический" f" порядок ({', '.join(p[0] for p in ROADMAP_SECTIONS)})") dirty.add(kind) + if kind == "backlog": + raw = {n for n, t in tasks.items() if raw_research(lay, t)} + if (moved := raw_last(lines, raw)) != lines: + lines[:] = moved + fixed.append(f"{lay.name(kind)}: сырьё снесено в конец своей секции" + f" ({len(raw)} записей `{RESEARCH}` без раздела" + f" «{lay.cfg['question_heading']}»)") + dirty.add(kind) if spaced_sections(lines) != lines: fixed.append(f"{lay.name(kind)}: отбивка после заголовков секций") dirty.add(kind) @@ -2271,16 +2651,9 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: _, 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: + if not stage(task, section=heading): continue - files[task["path"]] = "\n".join(rebuilt) + "\n" - fixed.append(f"{name}: секция в мете «{task['section_raw']}» → «{heading}»") + fixed.append(f"{name}: место в мете «{task['section_raw']}» → «{heading}»") plan = Plan() for path, text in files.items(): @@ -2316,8 +2689,13 @@ def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str], f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/.md`\n" "+ строка здесь. Целей тут нет — они в " f"[{lay.name('roadmap')}]({lay.name('roadmap')}): беклог — то, что берут,\n" - "роадмап — то, подо что берут. Порядка внутри секции нет: «что делать\n" - f"дальше» отвечает набор спринта. Ведётся скиллом `tasks`.\n\n" + "роадмап — то, подо что берут. Порядка «по важности» внутри секции нет:\n" + "«что делать дальше» отвечает набор спринта. Единственное исключение\n" + f"производно от типа — сырьё (`{RESEARCH}` без раздела" + f" «{lay.cfg['question_heading']}»)\nстоит в конце секции: его не берут." + " Ведётся скиллом `tasks`.\n\n" + "Тип записи стоит первым полем меты и решает, что у неё может быть:\n" + + "".join(f"{TYPE_EMOJI[t]} `{t}` " for t in TAKEABLE) + "\n\n" "Секции «блокеры» здесь нет и не заводится: блокер — это состояние\n" "(спринт не может продолжаться ни одной задачей), оно живёт до ответа\n" "человека, а его следы — вопросами в файлах задач.\n\n" @@ -2325,8 +2703,8 @@ def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str], out[lay.index("roadmap")] = ( "# Роадмап\n\n" "Состояние проекта: что приложение **уже умеет** и чего ещё не умеет.\n" - f"Цель — возможность приложения, файл `[goal]` в `{lay.cfg['items']}/`; её\n" - "задачи здесь **не перечисляются** — перечень даёт\n" + f"Цель — возможность приложения: файл типа `{GOAL}` ({TYPE_EMOJI[GOAL]}) в\n" + f"`{lay.cfg['items']}/`. Её задачи здесь **не перечисляются** — перечень даёт\n" "`tasks.py list --goal <слаг>`.\n\n" f"- **{ROADMAP_SECTIONS[PLANNED][0]}** — очередь значима и обосновывается прозой;\n" f"- **{ROADMAP_SECTIONS[DIRECTIONS][0]}** — очереди нет, тянутся долго;\n" @@ -2434,10 +2812,9 @@ def scan_old_backlog(src: Path) -> dict: text = path.read_text(encoding="utf-8") lines = text.splitlines() title = lines[0].removeprefix("#").strip() if lines and lines[0].startswith("#") else "" - kind = PLAIN_TYPE - bare = title - if (m := TYPE_PREFIX.match(title)): - kind, bare = m.group(1).strip().lower(), m.group(2).strip() + kind, bare = title_parts(title) + if kind == LEGACY_IDEA: + kind = RESEARCH entry = entries.get(path.name, {}) old_section, reason = "", "" body_start = 1 @@ -2523,7 +2900,7 @@ def scan_list_file(path: Path) -> dict: continue found["items"].append({ "old_slug": "", "slug": "", "title": clean[:120], - "type": PLAIN_TYPE, "old_section": heading, "section": "", "reason": "", + "type": "", "old_section": heading, "section": "", "reason": "", "why": "", "goal": "", "in_index": False, "translit": False, "questions_heading": "", "source": f"{path}:{num}", "body": text, "done": state.lower() == "x", @@ -2731,8 +3108,14 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: problems.append(f"слаг «{slug}» встречается дважды") slugs.add(slug) if it.get("section", "").lower() not in known_sections: - problems.append(f"{slug}: секция «{it.get('section', '')}» не из беклога" + problems.append(f"{slug}: категория «{it.get('section', '')}» не из беклога" f" ({', '.join(sections)})") + if (e := bad_type(it.get("type") or None)): + problems.append(f"{slug}: {e}") + elif not it.get("type"): + problems.append(f"{slug}: тип не назван — заполни `type` в карте" + f" ({', '.join(TAKEABLE)}). Машина его не угадывает:" + f" от типа зависит, каких разделов запись требует") if it.get("goal") and it["goal"] not in goal_slugs: problems.append(f"{slug}: цель «{it['goal']}» не заведена в карте") for e in (bad_why(it.get("why")), bad_reason(it.get("reason"))): @@ -2752,13 +3135,14 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: renames: dict[str, str] = {} for g in pl.get("goals", []): - title = f"[{GOAL}] {g['title']}" - meta = build_meta(g["section"].lower(), g.get("reason", ""), g.get("why", ""), - g.get("tags", [])) + title = h1_of(GOAL, g["title"]) + meta = build_meta(GOAL, g["section"].lower(), g.get("reason", ""), + g.get("why", ""), g.get("tags", [])) body = g.get("body", "").strip() wr.file(lay.items / f"{g['slug']}.md", - f"# {title}\n\n{meta}\n\n{body}\n\n## Завершение\n\n" - f"\n") + f"# {title}\n\n{meta}\n\n{body}\n\n" + f"## {lay.cfg['completion_heading']}\n\n" + f"\n") insert_entry(roadmap_lines, g["section"].lower(), entry_line(lay, title, g["slug"], g.get("why", ""))) @@ -2766,8 +3150,8 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: slug = it.get("slug") or it["old_slug"] if it.get("old_slug") and it["old_slug"] != slug: renames[it["old_slug"]] = slug - kind = (it.get("type") or PLAIN_TYPE).lower() - title = it["title"] if kind == PLAIN_TYPE else f"[{kind}] {it['title']}" + rtype = (it.get("type") or "").lower() + title = h1_of(rtype, it["title"]) tags = list(it.get("tags", [])) if it.get("goal"): tags.append(f"{GOAL_TAG}{it['goal']}") @@ -2788,7 +3172,8 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: body, count=1, flags=re.M | re.I) if QUESTION_TAG not in tags: tags.append(QUESTION_TAG) - meta = build_meta(it["section"].lower(), it.get("reason", ""), it.get("why", ""), tags) + meta = build_meta(rtype, it["section"].lower(), it.get("reason", ""), + it.get("why", ""), tags) wr.file(lay.items / f"{slug}.md", f"# {title}\n\n{meta}\n\n{body}\n") insert_entry(backlog_lines, it["section"].lower(), entry_line(lay, title, slug, it.get("why", ""))) @@ -2835,17 +3220,16 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: # --- честно про переходное состояние: считаем по написанным файлам --- written = tasks_of(lay) - # У идеи цель не обязательна — check это ошибкой не считает, и мы тоже. - no_goal = [n for n, t in written.items() if t["type"] == PLAIN_TYPE and not t["goal"]] - no_crit = [n for n, t in written.items() - if t["type"] == PLAIN_TYPE - and not criteria_stats(lay, t["body"].get( - lay.cfg["criteria_heading"].lower(), ""))[0] >= CRITERIA_MIN] + # Цель обязательна только у новой возможности: fix, chore и research живут + # без неё законно, и check об этом молчит. + no_goal = [n for n, t in written.items() if t["type"] in NEEDS_GOAL and not t["goal"]] + unfit = [n for n, t in written.items() + if t["type"] in TAKEABLE and schema_verdict(lay, t)[0]] print("\nпереходное состояние — назови его в докладе целиком:") - print(f" задач без цели: {len(no_goal)} — это ОШИБКИ check" + print(f" задач типа {'/'.join(NEEDS_GOAL)} без цели: {len(no_goal)} — это ОШИБКИ check" f" (правится `tasks.py edit <слаг> --goal <цель>`)" + (f": {', '.join(sorted(x[:-3] for x in no_goal)[:5])}…" if no_goal else "")) - print(f" задач без {CRITERIA_MIN}+ критериев приёмки: {len(no_crit)} —" + print(f" задач, не собравших разделы своего типа: {len(unfit)} —" f" check это ошибкой не считает, но `sprint take` их не возьмёт:" f" собрать спринт сегодня физически нечем") print(f" закрывается порциями переоценки по 5–8 задач (скилл session, шаг 3):" @@ -2872,40 +3256,40 @@ def main() -> int: p = sub.add_parser("list", help="список задач и целей") p.add_argument("--dir") p.add_argument("--stale", action="store_true", help="от самой залежавшейся") - p.add_argument("--section") - p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE)) + p.add_argument("--section", help="категория беклога или часть роадмапа") + p.add_argument("--type", choices=TYPES) p.add_argument("--tag", help="тег или список через запятую (нужны ВСЕ):" " goal:<слаг>, question, sprint:<слаг>") p.add_argument("--goal", help="задачи одной цели (перечень выводится, а не хранится)") - p.add_argument("--kind", choices=KINDS, help="род работы → тег kind:<род>") + p.add_argument("--raw", action="store_true", + help=f"только сырьё: {RESEARCH} без раздела «Вопрос»") p.add_argument("--index", choices=("backlog", "sprint", "roadmap", "all")) p.add_argument("--questions", action="store_true", help="только с открытым вопросом") - p = sub.add_parser("add", help="завести задачу, идею или цель") + p = sub.add_parser("add", help="завести запись: цель, задачу или разведку") p.add_argument("--dir") p.add_argument("--slug", required=True) p.add_argument("--title", required=True) - p.add_argument("--type", choices=TYPES) - p.add_argument("--section") + p.add_argument("--type", choices=TYPES, required=True, + help="тип решает схему записи: разделы, цель, право на спринт") + p.add_argument("--section", help="категория беклога или часть роадмапа") p.add_argument("--goal", help="слаг цели → тег goal:<слаг>") - p.add_argument("--kind", choices=KINDS, help="род работы → тег kind:<род>") p.add_argument("--why") p.add_argument("--reason") p.add_argument("--tag") - p = sub.add_parser("edit", help="сменить заголовок/«зачем»/тип/род/цель/теги") + p = sub.add_parser("edit", help="сменить заголовок/«зачем»/тип/цель/теги") p.add_argument("slug") p.add_argument("--title") p.add_argument("--why") - p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE)) + p.add_argument("--type", choices=TYPES) p.add_argument("--goal", help="заменить тег goal:<слаг>") - p.add_argument("--kind", choices=KINDS, help="заменить тег kind:<род>") p.add_argument("--add-tag", dest="add_tag") p.add_argument("--rm-tag", dest="rm_tag") p.add_argument("--section", help="только вместе со сменой типа, меняющей индекс") p.add_argument("--dir") - p = sub.add_parser("move", help="перенести в другую секцию беклога или часть роадмапа") + p = sub.add_parser("move", help="перенести в другую категорию беклога или часть роадмапа") p.add_argument("slug") p.add_argument("--section", required=True) p.add_argument("--reason")