diff --git a/DECISIONS.md b/DECISIONS.md index 360cfdf..38752b0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1348,3 +1348,82 @@ SSS: рубрика на узел без нового понятия порож «привести к одному виду перед сравнением»), несёт правило идентичности и потому `deep`. Одно слово в описании задачи попадает в разные ступени — это не противоречие, смотрят не на слово. + +## 19. Роадмап — состояние проекта, а не очередь работ (2026-08-04) + +### Что было + +Основной инструмент владельца — роадмап и набор целей: «на каком этапе проект, +что сделали и что осталось». Оценка идёт по **поведению**, а не по внутреннему +устройству: что приложение уже может делать и чего ещё не может. Отсюда +требование к формулировкам: цель отвечает на «что приложение будет делать», +задача — на «что для этого нужно сделать». + +Разбор показал, что инструмент отвечал ровно на половину этого вопроса. + +### Решено + +**YYY. Достигнутая цель из роадмапа не исчезает.** `close --implemented` удалял +у цели и файл, и строку — роадмап по построению показывал только «что осталось». +Свидетельство нашлось в самом роадмапе healthlog: там руками заведена секция «Что +уже пройдено» на двадцать строк прозы, и заканчивается она фразой «Эти звенья +целями не заведены: закрытая цель записи не оставляет, ей хватает коммита и +спеки». Обходной путь и его причина записаны рукой владельца. Теперь строка с +датой переезжает в секцию достигнутого; файл удаляется по-прежнему. + +Вторым домом поведения это не делает: нормативное поведение живёт в +`openspec/specs/`, роадмап отвечает **когда и в каком порядке** оно появилось — +другой вопрос. Ссылки на файл в строке нет намеренно: файла больше нет, а битая +ссылка это законная ошибка `check`. Форма строки — как в `REJECTED.md`, и по той +же причине. + +**ZZZ. Цель — возможность приложения, задача — шаг к ней.** Заголовок цели +отвечает на «что приложение будет уметь»: не «Работа со слиянием», а «Исход +слияния не зависит от порядка доставки». **Свойство поведения — тоже +возможность**: «сообщает о своём состоянии», «исход не зависит от порядка» — +законные цели, переформулировки в функцию не требуют. Единственный настоящий +чужак — работа над инструментом и процессом: на вопрос «что приложение будет +уметь» она не отвечает и живёт в отдельной секции роадмапа. + +**ААА. Тест готовности задачи сменил защиту.** Требование «что станет наблюдаемо +иначе снаружи» переехало к цели. У задачи вместо него — **какую строку +«Завершения» своей цели она двигает**. «Отрефакторить X» проваливает тест не +потому, что невидим снаружи, а потому, что не находит строки, к которой +относится. Побочная выгода: видно и обратное — строка «Завершения», к которой не +относится ни одна задача, это незакрытая часть возможности. Отсюда требование к +«Завершению» быть **списком**, а не абзацем: на абзац не сошлёшься. + +**БББ. Цель обязательна не у всякой задачи.** Прежнее правило — «у каждой задачи +должен быть `goal:`, иначе она не попадёт ни в один спринт» — было угрозой, а не +аргументом, и заставляло операционную работу выдумывать себе направление. +Граница проходит по роду работы: `feature` без цели не бывает (новая возможность +и есть содержание цели), `fix`, `chore` и `research` живут без цели законно и +входят в набор спринта помимо его цели. Это второй раз, когда род работы +окупается, — и первый, когда он что-то определяет за пределами отбора. + +**ВВВ. Тип `[epic]` упразднён.** Зонтик между целью и задачами не нужен: зонтиком +стала цель, а слишком крупный шаг дробится на шаги помельче под ней. Замер: ноль +употреблений на 97 записей двух живых проектов, при том что тип занимал место в +словаре, тесте готовности, автомате переходов, `split.md` и трёх местах +`tasks.py`. + +**ГГГ. Имена секций роадмапа временные.** `умеет` / `строим` / `направления` / +`станок` приняты как рабочие и признаны неудачными на месте — особенно `станок` +(слово пришло из `CLAUDE.md`, где «общий станок» уже значит инструмент, +врывающийся в спринт). Вопрос запаркован до отдельного захода. + +### Что из этого следует + +78. **Секция достигнутого — единственная, чьё имя знает скрипт.** Остальные + берутся из заголовков индекса как есть; в эту `close` пишет сам, поэтому её + имя живёт в `docs/.pm.json`, ключ `tasks.achieved_section`. +79. **`reopen` цели снимает строку достигнутого.** Иначе роадмап продолжает + утверждать, что приложение умеет то, что вернулось в работу. +80. **Прозаический раздел в индексе — дрейф.** Любой `##` проверка считает + секцией, поэтому «Что уже пройдено» и «Почему в таком порядке» в healthlog + формально были двумя лишними секциями, куда могла уехать задача. При + повышении они разбираются: звенья — строками в `умеет`, обоснование очереди — + прозой внутри `строим`. +81. **Правил стало пять, и нулевое — про смысл, а не про механику.** «Цель — + возможность, задача — шаг к ней» стоит перед правилами о гниении беклога и + производности индексов, потому что из него следует, зачем эти механики нужны. diff --git a/README.md b/README.md index 708924f..b56734a 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ docs/ research/ что показала реальность; числа с провенансом adr/ почему — промоут поверх архивных design.md review.md настройка конвейера + журнал дефектов - tasks/ цели, беклог, спринт, отклонённое + tasks/ роадмап (что умеет), беклог, спринт, отклонённое openspec/ specs//spec.md что система делает — нормативно changes/archive/ архив изменений с design.md diff --git a/TODO.md b/TODO.md index 5903565..1f615e8 100644 --- a/TODO.md +++ b/TODO.md @@ -167,3 +167,15 @@ jellybit 43. Шаги повышения — [changelog.md](av-dev-pm/skills/can - [ ] род работы и раздел «Затрагивает» — **не задним числом**: сперва то, что идёт в ближайший набор (`sprint take` без них откажет), остальное по ходу переоценки (PPP) +- [ ] секции роадмапа: `порядок` → `строим`, `темы` → `направления`, завести + `умеет` и `станок`; прозаические разделы healthlog («Что уже пройдено», + «Почему в таком порядке») разложить — звенья строками в `умеет`, + обоснование очереди прозой внутри `строим` (тема 19, 80) +- [ ] переформулировать цели ответом на «что приложение будет уметь»; цели не + про приложение («Процесс и качество разработки» в jellybit) — в `станок` + +## 7. Имена секций роадмапа — выбрать хорошие (тема 19, ГГГ) + +- [ ] `умеет` / `строим` / `направления` / `станок` приняты как временные. + Особенно плох `станок`; заодно проверить, не плохо ли то же слово в + `CLAUDE.md`, откуда оно взято («общий станок, врывающийся в спринт») diff --git a/av-dev-pm/skills/canon/references/canon.md b/av-dev-pm/skills/canon/references/canon.md index a881cfe..64bca13 100644 --- a/av-dev-pm/skills/canon/references/canon.md +++ b/av-dev-pm/skills/canon/references/canon.md @@ -200,13 +200,24 @@ kebab-case. ### `tasks/` -Раскладку, форму записи и команды держит скилл `tasks` — канон фиксирует только -имена файлов (`items/`, `ROADMAP.md`, `BACKLOG.md`, `SPRINT.md`, `REJECTED.md`) и -два требования к самой записи, потому что от них зависит, можно ли задачу +Раскладку, форму записи и команды держит скилл `tasks` — канон фиксирует имена +файлов (`items/`, `ROADMAP.md`, `BACKLOG.md`, `SPRINT.md`, `REJECTED.md`) и то, +от чего зависит, читается ли проект как продукт. + +**`ROADMAP.md` отвечает на «что приложение уже умеет и чего ещё не умеет».** Это +не очередь работ: цель — **возможность приложения**, задача — шаг к ней. +Достигнутая цель из роадмапа **не исчезает** — строка с датой переезжает в +секцию достигнутого, потому что «что умеет» и есть половина вопроса, ради +которого документ открывают. Вторым домом поведения роадмап при этом не +становится: нормативное поведение живёт в `openspec/specs/`, роадмап отвечает, +**когда и в каком порядке** оно появилось. + +Плюс два требования к записи задачи, потому что от них зависит, можно ли её оценить: - **род работы** тегом `kind:<род>` из закрытого словаря `feature` | `fix` | - `chore` | `research` — у задачи обязателен, у цели запрещён; + `chore` | `research` — у задачи обязателен, у цели запрещён. Он же решает, + нужна ли цель: у `feature` обязательна, у остальных нет; - **раздел «Затрагивает»** в теле задачи — границы, которых изменение касается (эндпоинт, таблица и миграция, формат на диске, публичный тип пакета). @@ -252,7 +263,7 @@ kebab-case. | почему решено так | `adr/`, источник — архивный `design.md` | | граница домена, «чем не является» | `passport.md` | | инвариант и его severity | `CLAUDE.md` | -| порядок работ и его обоснование | `docs/tasks/ROADMAP.md` | +| что приложение умеет и чего не умеет; порядок работ | `docs/tasks/ROADMAP.md` | | измеренное число | `research/` | | настройка с числовым значением | `database.md` | | периметр и модель угроз | `security.md` | diff --git a/av-dev-pm/skills/canon/references/changelog.md b/av-dev-pm/skills/canon/references/changelog.md index 4d0e01f..a291496 100644 --- a/av-dev-pm/skills/canon/references/changelog.md +++ b/av-dev-pm/skills/canon/references/changelog.md @@ -15,10 +15,11 @@ upgrade` идёт по записям снизу вверх от версии п ## Версия 3 — 2026-08-04 -Оглавление целей переименовано, у задач появился род работы и раздел -«Затрагивает», сменилось умолчание профиля ревью. Раскладка меняется в одном -файле, но переименование тянет за собой ссылки, поэтому шаги делаются одним -заходом. +Роадмап стал **состоянием проекта**, а не очередью работ: цель — возможность +приложения, задача — шаг к ней, достигнутое из роадмапа не исчезает. Плюс род +работы, раздел «Затрагивает» и новое умолчание профиля ревью. Раскладка меняется +в одном файле, но переименование и смена секций тянут за собой ссылки, поэтому +шаги делаются одним заходом. **Что добавилось:** @@ -30,20 +31,30 @@ upgrade` идёт по записям снизу вверх от версии п 2. **Раздел «Затрагивает»** в теле задачи — перечень границ, которых изменение касается (эндпоинт, таблица и миграция, формат на диске, публичный тип). Как и критерии приёмки, требуется к взятию в спринт, а не к заведению. -3. **Умолчание профиля ревью сменилось** — это не раскладка, но проектный текст +3. **Секции роадмапа** — четыре вместо двух: `умеет` (достигнутые цели строкой + с датой, без ссылки на файл), `строим` (очередь значима), `направления` + (очереди нет), `станок` (инструмент и процесс, не возможности приложения). + Имя секции достигнутого скрипт знает по конфигу — `tasks.achieved_section`. + Имена **временные** и будут пересмотрены (DECISIONS, тема 19). +4. **Умолчание профиля ревью сменилось** — это не раскладка, но проектный текст под него уже написан. `standard` стал рабочим умолчанием: миграция схемы, публичный контракт и инвариант ступень больше **не** поднимают, `wide` означает новое понятие или структурную единицу. Подраздел «Триггеры профиля» в `docs/review.md` остаётся на месте, но его содержимое надо перечитать. -**Что переехало:** `docs/tasks/PLAN.md` → `docs/tasks/ROADMAP.md`. Вместе с +**Что переехало:** `docs/tasks/PLAN.md` → `docs/tasks/ROADMAP.md`; достигнутая +цель — из небытия в секцию `умеет`: `close <цель> --implemented` удаляет файл, но +**оставляет строку с датой**. Прежде роадмап отвечал только «что осталось», и +половину его вопроса вели прозой руками. Вместе с файлом переименован ключ конфига `tasks.plan` → `tasks.roadmap` и токены команд: `--index plan` → `--index roadmap`, `init --plan-sections` → `--roadmap-sections`, `init --plan` → `--roadmap`. Старый ключ в `docs/.pm.json` не игнорируется молча — `tasks.py` останавливается и называет переименование. -**Что удалено:** ничего. +**Что удалено:** тип `[epic]`. Он был зонтиком между целью и задачами; зонтиком +стала цель, а слишком крупный шаг дробится на шаги помельче под ней. Ноль +употреблений на 97 записей двух живых проектов. **Что сделать проекту:** @@ -63,7 +74,19 @@ upgrade` идёт по записям снизу вверх от версии п что для этого проекта считается **новым понятием** и **правилом идентичности**, — и убрать остальное, иначе проект возвращает себе прежнюю частоту полного набора уточнением. -7. `docs/.pm.json`: `"canon": 3`. +7. Переименовать секции роадмапа: `порядок` → `строим`, `темы` → + `направления`; завести `умеет` **первой** и `станок` последней. Прозаические + разделы вроде «Что уже пройдено», которые велись руками, разложить: звенья — + строками в `умеет` (дата, слаг, что стало возможно), обоснование очереди + оставить прозой в `строим`. Любой `##` в индексе проверка считает секцией, + поэтому прозаический заголовок здесь — дрейф. +8. Переформулировать цели ответом на **«что приложение будет уметь»**: не + «Работа со слиянием», а «Исход слияния не зависит от порядка доставки». + Свойство поведения — законная цель. Цель, которая не про приложение + (процесс, инструмент), переезжает в `станок`. +9. `[epic]`, если он в проекте заводился: это либо цель, либо набор задач под + общей целью. `check` назовёт его неизвестным типом. +10. `docs/.pm.json`: `"canon": 3`. ## Версия 2 — 2026-08-03 diff --git a/av-dev-pm/skills/init/SKILL.md b/av-dev-pm/skills/init/SKILL.md index b57803f..070bd68 100644 --- a/av-dev-pm/skills/init/SKILL.md +++ b/av-dev-pm/skills/init/SKILL.md @@ -48,7 +48,8 @@ description: "Завести новый проект — сессия вопро проекте нельзя откатить — деплой, выкладка наружу, перезапись данных. 5. **Чем краснеет гейт.** Какие проверки обязательны; что красит безусловно; чего в гейте намеренно не будет и кто тогда это гоняет. -6. **Первые цели.** Направления, а не задачи: три-пять целей в «порядок», с +6. **Первые цели.** Возможности приложения, а не задачи: три-пять целей в + `строим`, каждая — ответ на «что приложение будет уметь», с обоснованием очереди прозой. ### Как вести diff --git a/av-dev-pm/skills/session/references/cadence.md b/av-dev-pm/skills/session/references/cadence.md index 1f33d3f..3541ebf 100644 --- a/av-dev-pm/skills/session/references/cadence.md +++ b/av-dev-pm/skills/session/references/cadence.md @@ -115,13 +115,15 @@ 6. **Жива ли она вообще.** Контекст мог измениться: ушла зависимость, отпал сценарий, обошли иначе. Здесь и звучит вопрос о выкидывании. -7. **Та ли цель.** Приоритетов нет, и «повысить» нечего — вместо повышения - **смена цели** (`edit --goal <другой>`) или включение в ближайший - набор. Задача, которой не находится цель, — кандидат на выход: она не попадёт - ни в один спринт. +7. **Та ли цель — и нужна ли она вообще.** Приоритетов нет, и «повысить» нечего + — вместо повышения **смена цели** (`edit --goal <другой>`) или + включение в ближайший набор. `feature`, которой не находится цель, — кандидат + на выход: новая возможность вне цели это возможность, которой никто не + заказывал. Операционной задаче (`fix`, `chore`, `research`) цель не нужна, и + выдумывать её здесь не надо. 8. **Задача ли это по-прежнему.** Не проходит тест «готова к взятию» → `edit - --type idea`, дальше штурм. Разрослась → `edit --type epic`, - дальше декомпозиция. + --type idea`, дальше штурм. Разрослась → это несколько задач под той + же целью, дальше декомпозиция. 9. **Переоценка по измеренному.** Спринт производит числа — сколько на самом деле стоит такая работа, что оказалось дороже ожидания. Эти числа меняют цену **других** задач, и именно здесь это применяется: задача, чья цена выросла @@ -173,15 +175,18 @@ ## Шаг 4. Выбор цели и набор спринта -1. **Покажи состояние целей**: «порядок» `ROADMAP.md` с обоснованием очереди, темы, и +1. **Покажи состояние проекта**: секцию `умеет` (что уже сделано — это половина + ответа на «где мы»), затем `строим` с обоснованием очереди, `направления`, и по каждой цели-кандидату — сколько под ней задач без открытых вопросов (`list --goal <слаг>`). Цель без готовых задач набором не станет: её сперва надо декомпозировать. 2. **Цель называет человек.** Это продуктовое решение, а не механика: агент предлагает и объясняет, но не выбирает. 3. **Набор собирает агент** — `sprint start --goal <слаг>`, затем `sprint take - …`. Скрипт не даст взять чужую цель, идею, эпик, задачу с открытым вопросом, - без критериев приёмки, без рода работы или без раздела «Затрагивает». + …`. Скрипт не даст взять цель, идею, задачу с чужой целью, с открытым + вопросом, без критериев приёмки, без рода работы или без раздела + «Затрагивает». Задача без цели вовсе (`fix`, `chore`, `research`) берётся + свободно — операционная работа входит в набор помимо его цели. 4. **Набор показывается человеку до старта работ.** Показ — это и есть момент заморозки: после него набор не двигается. **В показе называется состав по роду работы** — три `fix` и ни одной `feature` под целью развития это diff --git a/av-dev-pm/skills/session/references/sprint.md b/av-dev-pm/skills/session/references/sprint.md index 538dc5a..7c17a70 100644 --- a/av-dev-pm/skills/session/references/sprint.md +++ b/av-dev-pm/skills/session/references/sprint.md @@ -18,10 +18,10 @@ с вопросом в файле и **без живого незакоммиченного предложения** — иначе при следующем взятии оно столкнётся с новым. Наработки, которые жалко терять, переезжают в тело задачи текстом. -- **Переросла в эпик** — распознаётся **до того, как под неё заведено - предложение об изменении**, иначе его придётся выбрасывать. Помечается - `[epic]`, выходит из набора, уходит на декомпозицию; спринт продолжается - остальными, части в замороженный набор не добавляются. +- **Оказалась крупнее задачи** — распознаётся **до того, как под неё заведено + предложение об изменении**, иначе его придётся выбрасывать. Выходит из набора, + уходит на декомпозицию; спринт продолжается остальными, части заводятся под той + же целью и в замороженный набор не добавляются. - **Отменена решением по ходу** — `close --reason "<ссылка на решение>"` прямо из спринта. Это редкий, но законный исход, и он называется в докладе. @@ -34,7 +34,7 @@ flowchart TD take["sprint take — задача в наборе"] done["сделана
close --implemented"] out["вышла
sprint drop --reason"] - epic["переросла в эпик
распознаётся до заведения change"] + epic["крупнее задачи
распознаётся до заведения change"] cancel["отменена решением по ходу
close --reason"] all{"по каждой задаче набора
наступил исход?"} harvest["урожай заводится интейком tasks"] diff --git a/av-dev-pm/skills/tasks/SKILL.md b/av-dev-pm/skills/tasks/SKILL.md index fa4b655..4e65f96 100644 --- a/av-dev-pm/skills/tasks/SKILL.md +++ b/av-dev-pm/skills/tasks/SKILL.md @@ -13,10 +13,17 @@ description: Ведение задач и целей как каталога mar прошедшего спринта → переоценка → выбор цели и набор) — это скилл `session`; и выполнением задачи — это пайплайн проекта. -## Четыре правила, из которых всё следует +## Пять правил, из которых всё следует Ситуация не покрыта инструкцией — решай по ним. +0. **Цель — возможность приложения, задача — шаг к ней.** Цель отвечает на «что + приложение будет уметь», её «Завершение» — наблюдаемый признак того, что уже + умеет; задача отвечает на «что для этого нужно сделать». Оценивается проект + по **поведению**, а не по внутреннему устройству, поэтому и роадмап отвечает + не «сколько работ осталось», а «что уже умеет и чего ещё не умеет». + Свойство поведения — тоже возможность: «сообщает о своём состоянии», + «исход слияния не зависит от порядка доставки» — законные цели. 1. **Беклог гниёт с той стороны, где его пополняют.** Заведение — самая частая операция и с худшим отказом: из одного разговора рождается пять файлов, а переоценка потом разгребает то, чего не надо было заводить. Дедупликация и @@ -33,11 +40,13 @@ description: Ведение задач и целей как каталога mar 3. **Причина переживает запись.** Выкинутая без причины задача вернётся через квартал тем же текстом. Реализованная оставляет след в коммите — выкинутая не оставляет ничего, поэтому у неё есть `REJECTED.md`. -4. **Порядка нет, есть цель.** Ни в секциях, ни списком: «что делать дальше» - отвечает набор спринта, а между спринтами порядок не нужен никому — брать - задачи вне спринта запрещает заморозка. Поэтому нет ни приоритетов, ни - «повысить», ни «встать раньше»: вместо повышения — смена цели или включение - в набор. +4. **Порядка нет, есть цель — но цель есть не у всякой задачи.** Приоритетов, + «повысить» и «встать раньше» нет: «что делать дальше» отвечает набор спринта, + а между спринтами порядок не нужен никому. Цель обязательна там, где она и + есть содержание работы, — у **новой возможности** (`kind:feature`). Починка, + техдолг и разведка служат работоспособности, а не направлению, и живут без + цели законно; в набор спринта они входят помимо его цели. Придуманная им цель + — то же враньё, от которого спасает род работы. ## Раскладка @@ -48,7 +57,7 @@ description: Ведение задач и целей как каталога mar ``` docs/tasks/ items/ задачи и цели файлами, .md, слаги английские - ROADMAP.md оглавление целей: порядок (значим) и темы (без порядка) + ROADMAP.md состояние проекта: что уже умеет и чего ещё не умеет BACKLOG.md что можно взять — только задачи, целей здесь нет SPRINT.md текущий спринт: цель, набор, дата REJECTED.md ушедшее БЕЗ реализации, с причиной и датой @@ -58,6 +67,20 @@ docs/tasks/ подо что берут.** Цель в спринт взять нельзя, поэтому в списке берущихся ей не место. +**Четыре секции роадмапа, и первая отвечает на половину вопроса:** + +| Секция | Что в ней | +| --- | --- | +| `умеет` | достигнутые цели — строкой с датой, **без ссылки на файл**: файл удалён, поведение живёт в спеках | +| `строим` | очередь значима и обосновывается прозой рядом | +| `направления` | очереди нет, тянутся долго | +| `станок` | инструмент и процесс разработки — не возможности приложения, и потому отдельно | + +Имена секций сейчас **временные**: они читаются хуже, чем должны, и будут +пересмотрены (`DECISIONS.md`, тема 19). Проект вправе назвать свои иначе — +секции берутся из заголовков индекса, — но дом достигнутого скрипт обязан знать +по имени, и оно живёт в `docs/.pm.json`, ключ `tasks.achieved_section`. + **Секции «блокеры» в беклоге нет.** Блокер — это *состояние* (спринт не может продолжаться ни одной задачей), а не полка: он живёт ровно до ответа человека, и записи в такой секции не успевают жить. Следы блокера остаются вопросами в @@ -77,6 +100,15 @@ docs/tasks/ даром: `SPRINT.md` лежит под git, `git log -p docs/tasks/SPRINT.md` отдаёт историю всех наборов без отдельного журнала. +**У достигнутой цели запись остаётся, и это единственное исключение.** Файл +удаляется так же, а строка переезжает в секцию `умеет` с датой. Причина в том, +что цель — не работа, а **возможность**: «что приложение умеет» это половина +вопроса, ради которого роадмап и открывают, и стирать её вместе с файлом значит +оставить инструмент, отвечающий только «что осталось». Вторым домом это не +становится: поведение живёт в `openspec/specs/`, а роадмап отвечает **когда и в +каком порядке оно появилось** — другой вопрос. Ссылки на файл в строке нет +намеренно: файл удалён, а битая ссылка — законная ошибка `check`. + Куда запись может переехать и какой командой — весь набор переходов: ```mermaid @@ -86,6 +118,7 @@ stateDiagram-v2 state "SPRINT.md — набор спринта" as S state "REJECTED.md — ушла без реализации" as R state "записи нет — реализована" as D + state "ROADMAP.md, «умеет» — цель достигнута" as A [*] --> B: add [*] --> P: add --type goal @@ -94,10 +127,13 @@ stateDiagram-v2 B --> S: sprint take S --> B: sprint drop --reason S --> D: close --implemented + P --> A: close --implemented B --> R: close --reason S --> R: close --reason + P --> R: close --reason D --> B: reopen --reason R --> B: reopen --reason + A --> P: reopen --reason ``` Состояния здесь — **где числится строка**, а не где лежит файл: файл @@ -110,36 +146,55 @@ stateDiagram-v2 ## Цели -**Цель — такой же файл в `items/`, тип `[goal]`**, перечисленный в `ROADMAP.md`: -либо цель из секции **порядок** — там очередь значима и обоснована прозой, — либо -**тематическая**, в порядок не встающая («прочность слияния», «журнал и -пересборка»). Без второй секции половина целей была бы нигде не перечислена: -находки ревью не служат ничему из порядка. +**Цель — возможность приложения.** Такой же файл в `items/`, тип `[goal]`, +перечисленный в `ROADMAP.md`. Формулируется ответом на вопрос **«что приложение +будет уметь»**, а не названием области работ: не «Работа с чтением», а «Чтение +данных клиентами»; не «Рефакторинг слияния», а «Исход слияния не зависит от +порядка доставки». + +**Свойство поведения — тоже возможность.** «Наблюдаемость» это «приложение +сообщает о своём состоянии»; «прочность слияния» это «исход не зависит от +порядка». Такие цели законны и переформулировки в функцию не требуют — требуют +только, чтобы формулировка отвечала на «что приложение делает», а не на «какую +часть кода мы трогаем». + +**Что целью не является — работа над станком.** Инструмент, процесс, сборка, +сам этот скилл: на вопрос «что приложение будет уметь» они не отвечают. Им +отведена отдельная секция роадмапа, чтобы они были видны в том же экране и при +этом не читались как возможности продукта. + +Секция выбирается так: очередь значима и обоснована прозой — `строим`; тянется +долго и очереди не имеет — `направления`; не про приложение — `станок`; +достигнутое кладёт туда сам `close`. - **Список задач цели выводится, а не хранится.** В теле цели — зачем она и что считается её завершением; перечня задач там нет. Он был бы третьим индексом и поехал бы на первой же закрытой задаче, а `check` про него не знает. Связь однонаправленна: задача несёт тег `goal:<слаг>`, перечень даёт `tasks.py list --goal <слаг>`. -- **Статус цели выводится.** Цель закрыта, когда у неё не осталось открытых +- **Статус цели выводится.** Цель достигнута, когда у неё не осталось открытых задач; `[x]`/`[~]` руками не ведутся, а `close` цели с живыми задачами - скрипт запретит. Единственная оговорка: цель без задач неотличима — «ещё не + скрипт запретит. Достижение — `close <цель> --implemented`: файл удаляется, + строка с датой переезжает в `умеет`. Ошиблись — `reopen` вернёт файл и + **снимет строку достигнутого**, чтобы роадмап не утверждал того, чего нет. Единственная оговорка: цель без задач неотличима — «ещё не разобрана» или «всё закрыто». Различает **тег `decomposed`** в мете цели: он ставится, когда цель разложена на задачи. Тег, а не строка в теле — потому что проверяется механически: `check` **напоминает** о нём у пустой цели (замечанием, не ошибкой — неразобранная цель это законное состояние), а `check --fix` сам проставляет его цели, у которой задачи есть. -- **`[goal]` и `[epic]` — разные вещи.** Цель **постоянна**: живёт, пока живёт - направление. Эпик **временен**: это задача, которая не мерджится целиком, её - разбирают, и он исчезает. Два срока жизни под одним словом разъезжаются, - поэтому слова два. +- **Тип `[epic]` упразднён.** Он был зонтиком между целью и задачами — «задача, + которая не мерджится целиком». Зонтик теперь цель, а слишком крупный шаг просто + дробится на шаги помельче под той же целью, и промежуточному типу места не + осталось. Замер подтвердил: ноль употреблений на 97 записей двух живых + проектов. Встретился в чужом беклоге — это цель либо набор задач, и `check` + назовёт его неизвестным типом. ## Род работы **Тип записи и род работы — две оси, и путать их нельзя.** Тип отвечает «что это -за запись» (цель, идея, эпик, задача), род — «какого рода работа»: `feature`, -`fix`, `chore`, `research`. Одним значением на оба вопроса не ответить: идея -бывает *про* функцию, эпик функцией *и является*. +за запись» (цель, идея, задача), род — «какого рода работа»: `feature`, `fix`, +`chore`, `research`. Одним значением на оба вопроса не ответить: идея бывает +*про* функцию, а цель функцией *и является*. - **`feature`** — снаружи появляется или меняется то, чего раньше не было. - **`fix`** — поведение расходится с заявленным, и расхождение воспроизводится. @@ -163,11 +218,18 @@ stateDiagram-v2 `defect`, — и отбор по роду перестанет отвечать на свой единственный вопрос. Ни один род не подходит — это сигнал, что в задаче их два и её надо разделить. -**Род обязателен у задачи, у цели запрещён, у идеи и эпика необязателен** — идея -получает его, когда становится задачей, а эпик исчезает после разбора, и род -несут его части. Требуется он там, где по нему принимают решение: `sprint take` -без рода откажет. `check` о пропаже только **напоминает** — беклог, заведённый до -появления рода, законен, и переоформлять его «заодно» здесь не просят. +**Род обязателен у задачи, у цели запрещён, у идеи необязателен** — идея получает +его, когда становится задачей. Требуется он там, где по нему принимают решение: +`sprint take` без рода откажет. `check` о пропаже только **напоминает** — беклог, +заведённый до появления рода, законен, и переоформлять его «заодно» здесь не +просят. + +**Род решает и то, обязательна ли цель.** `feature` без цели не бывает: новая +возможность и есть содержание цели, и если подходящей нет — либо она заводится, +либо это не `feature`. `fix`, `chore` и `research` живут без цели законно, и +`check` о них молчит: они служат работоспособности, а не направлению. Это +единственный случай, когда род что-то определяет за пределами отбора, — и +определяет он учёт, а не процесс проверки. **Род не выбирает профиль ревью и вообще ничего не предписывает пайплайну.** Профиль выбирается по факту изменения, а не по роду задачи: `chore` бывает @@ -216,7 +278,7 @@ 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|epic] [--section S] [--goal G] [--kind K] [--why «зачем»] [--tag a,b] +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 move S --dir D --section S [--reason R] [--after S | --first] python3 $tk close S --dir D --reason R # в REJECTED.md + удалить (ушла без реализации) @@ -240,9 +302,9 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап Различать 1 и 3 обязательно: «дрейф в беклоге» — рабочая ситуация, «каталога нет» — нерабочая, и одинаковая реакция на них была бы неверна в обоих случаях. -Тип — английское ключевое слово `goal` / `idea` / `epic` / `task` (как и прочие -токены команд); `task` префикса не несёт, остальные кодируются `[goal]`/ -`[idea]`/`[epic]` в заголовке. Текст задачи при этом русский. +Тип — английское ключевое слово `goal` / `idea` / `task` (как и прочие токены +команд); `task` префикса не несёт, остальные кодируются `[goal]`/`[idea]` в +заголовке. Текст задачи при этом русский. **Мутации правят файл и индексы заодно** — руками строку индекса или мету не пиши, зови `add`/`edit`/`move`/`close`/`sprint`. Смена заголовка, «зачем», типа, @@ -310,14 +372,14 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап молча заводить нельзя). Две задачи об одном — самая дорогая находка переоценки. 3. **Тип по тесту готовности** (см. task-format): проходит — задача, не - проходит — идея (`--type idea`), проходит по пользе, но не делается одним - заходом — эпик (`--type epic`, сперва декомпозиция). Направление, а не - работа — цель (`--type goal`). -4. **Цель задачи.** У каждой задачи должен быть `--goal <слаг>`: задача вне цели - не попадёт ни в один спринт. Подходящей цели нет — либо она заводится - (`--type goal` в «темы»), либо это сигнал, что задача никому не служит и - заводить её не надо. У идеи цели может не быть — она проставляется, когда - идея становится задачей. + проходит — идея (`--type idea`). Не делается одним заходом — это не эпик, а + несколько задач под одной целью: дроби сразу. Возможность приложения, а не + шаг — цель (`--type goal`). +4. **Цель задачи — если род её требует.** У `feature` должен быть + `--goal <слаг>`: новая возможность и есть содержание цели. Подходящей нет — + либо она заводится (`--type goal`), либо перед тобой не `feature`. У `fix`, + `chore` и `research` цели может не быть вовсе, и придумывать её не надо. У + идеи цель проставляется, когда идея становится задачей. 5. **Род работы** — `--kind feature|fix|chore|research` (см. «Род работы»). Не подходит ни один — задача не одна, разбирай. 6. `add …`, затем допиши тело редактором: одна фраза, **затрагиваемые границы**, diff --git a/av-dev-pm/skills/tasks/references/from-review.md b/av-dev-pm/skills/tasks/references/from-review.md index 27945a0..b03fe69 100644 --- a/av-dev-pm/skills/tasks/references/from-review.md +++ b/av-dev-pm/skills/tasks/references/from-review.md @@ -41,12 +41,15 @@ заведённое и уже выкинутое. Нашлось среди живых — дописываем находку в существующий файл. Нашлось в `REJECTED.md` — это сигнал: причина отказа могла устареть, выноси пользователю, а не заводи молча заново. -4. **Разложи по целям.** У каждой заводимой задачи должен быть `goal:<слаг>`. - Половина находок ревью не служит ничему из порядка — их цель **тематическая** - («прочность слияния», «журнал и пересборка», «наблюдаемость»). - Подходящей темы нет — заведи её целью (`add --type goal --section темы`) - в том же проходе: без цели задача не попадёт ни в один спринт, а значит не - будет сделана никогда. +4. **Разложи по целям — там, где цель нужна.** Большинство находок ревью это + `fix` и `chore`, и **цель им не требуется**: они служат работоспособности, а + не направлению, и в спринт входят помимо его цели. Придуманная им цель — + ровно то враньё, от которого спасает род работы. + + Цель обязательна у находки, которая оказалась **новой возможностью** + (`kind:feature`): нашлось поведение, которого никто не заказывал, и его надо + либо заказать целью, либо убрать. Подходящей цели нет — заведи её + (`add --type goal --section направления`) в том же проходе. 5. **Покажи карту до создания файлов.** Кластер → задача / идея / строка в пакетный файл / уже заведено / отброшено, и под какую цель — пачкой через `AskUserQuestion`. Это тот же барьер, что и «три кандидата» в интейке из diff --git a/av-dev-pm/skills/tasks/references/split.md b/av-dev-pm/skills/tasks/references/split.md index 00a510a..06dd0b1 100644 --- a/av-dev-pm/skills/tasks/references/split.md +++ b/av-dev-pm/skills/tasks/references/split.md @@ -1,7 +1,7 @@ # Декомпозиция и мозговой штурм Обе операции превращают одну запись в несколько (или в ноль). Разница во входе: -декомпозиция дробит **готовую задачу или эпик**, штурм прорабатывает **идею**, +декомпозиция дробит **слишком крупную задачу**, штурм прорабатывает **идею**, которая ещё не задача. ## Тест декомпозиции @@ -11,10 +11,11 @@ 1. **Мерджатся независимо.** Часть Б не требует, чтобы часть А была уже влита. Есть порядок «сперва А, потом Б, иначе не собрать» → это не декомпозиция, а план реализации: шаги остаются **внутри одного файла**. -2. **Каждая даёт видимую пользу.** Часть, полезная только в комплекте с другой, - — не самостоятельная задача. Пользу проверяй тестом «готова к взятию» - (task-format): что станет наблюдаемо иначе именно от этой части и какие у неё - собственные критерии приёмки. +2. **Каждая — самостоятельный шаг.** Часть, осмысленная только в комплекте с + другой, — не задача. Проверяй тестом «готова к взятию» (task-format): какую + строку «Завершения» цели двигает **именно эта часть** и какие у неё + собственные критерии приёмки. У операционных частей (`fix`, `chore`, + `research`) цели может не быть — тогда достаточно собственных критериев. Не проходит хотя бы одно — **не дроби**. Ложная декомпозиция плодит файлы, которые нельзя взять поодиночке, и переоценка потом склеивает их обратно. @@ -55,21 +56,21 @@ `REJECTED.md` здесь — не «выкинули», а именно тот след, что переживает запись: через квартал вопрос «куда делась задача X» отвечается строкой со ссылками на наследников, а не археологией git; -- родитель осмыслен как зонтик → `edit --type epic`, тело — ссылки на - задачи-части, своих шагов у него нет. **Эпик не берётся в спринт** и живёт - ровно до тех пор, пока не закрыта последняя часть. +- родитель осмыслен как **возможность**, а не как шаг → это цель. Тип на месте + не меняется (цель живёт в другом индексе): заводится `[goal]` в `ROADMAP.md`, + части получают `--goal <новый слаг>`, родитель закрывается с причиной-ссылкой. -Зонтик, который перестал быть временным и описывает направление, а не работу, — -это уже **цель**, а не эпик. Тип на месте не меняется (цель живёт в другом -индексе): заводится `[goal]` в `ROADMAP.md`, задачи получают `--goal <новый слаг>`, -эпик закрывается с причиной-ссылкой. +**Промежуточного зонтика между целью и задачей нет.** Тип `[epic]` упразднён: +роль зонтика играет цель, а слишком крупный шаг дробится на шаги помельче под +той же целью. Если частям нужен общий заголовок — значит у них общая +возможность, и её надо назвать целью, а не заводить временный тип. ## Когда декомпозиция случается посреди спринта -Задача, которая **переросла в эпик**, распознаётся до того, как под неё заведено -предложение об изменении: иначе его придётся выбрасывать. Она помечается -`[epic]`, выходит из набора (`sprint drop … --reason "переросла в эпик"`), уходит -на декомпозицию, а спринт продолжается остальными. Части заводятся сразу, но в +Задача, которая **оказалась крупнее задачи**, распознаётся до того, как под неё +заведено предложение об изменении: иначе его придётся выбрасывать. Она выходит +из набора (`sprint drop … --reason "крупнее задачи"`), уходит на декомпозицию, а +спринт продолжается остальными. Части заводятся сразу под той же целью, но в текущий набор **не добавляются** — набор заморожен. ## Мозговой штурм идеи @@ -100,7 +101,7 @@ Applicative-штурм («перечисли задачи, следующие и - Идея/задача на входе, выбранная рамка (для штурма), задачи-наследники со слагами, целями и секциями. -- Судьба родителя: удалён / стал эпиком / стал целью / выкинут с причиной. +- Судьба родителя: удалён / стал целью / выкинут с причиной. - `tasks.py check` после правок. - Границы покрытия: какие постановки рассмотрены и какие сознательно отброшены — чтобы штурм не пришлось повторять с нуля. diff --git a/av-dev-pm/skills/tasks/references/task-format.md b/av-dev-pm/skills/tasks/references/task-format.md index 81dc5fc..3189445 100644 --- a/av-dev-pm/skills/tasks/references/task-format.md +++ b/av-dev-pm/skills/tasks/references/task-format.md @@ -37,7 +37,7 @@ ``` - **Заголовок H1** — он же заголовок строки в индексе, дословно. Тип кодируется - префиксом `[goal]` / `[idea]` / `[epic]`; обычная задача — без префикса. + префиксом `[goal]` / `[idea]`; обычная задача — без префикса. Отдельного поля типа **нет**: два места для одного факта разъезжаются, а префикс виден прямо в индексе, где и принимается решение «брать или не брать». - **Мета-блок** — список сразу после заголовка, **поле на строку**. Обязательна @@ -152,10 +152,14 @@ ## Файл цели -```markdown -# [goal] Прочность слияния +**Заголовок цели отвечает на «что приложение будет уметь».** Не область работ и +не имя подсистемы: не «Работа со слиянием», а «Исход слияния не зависит от +порядка доставки». Свойство поведения — тоже возможность. -- **Секция:** темы +```markdown +# [goal] Исход слияния не зависит от порядка доставки + +- **Секция:** направления - **Теги:** decomposed Ради чего: точки из разных доставок сходятся в один часовой объект, и сегодня @@ -163,14 +167,18 @@ ## Завершение -Достигнута, когда исход слияния не зависит ни от порядка, ни от времени -доставки, и это подтверждено повторным прогоном на живом корпусе. +- повторная доставка тех же точек в другом порядке даёт то же состояние; +- накопительная метрика за сутки не уменьшается после повторной доставки; +- в логе видно, какая из двух точек выиграла и почему. ``` - **Задачи цели здесь не перечисляются.** Перечень даёт `tasks.py list --goal <слаг>`; хранимый список стал бы третьим индексом и поехал бы на первой же закрытой задаче. -- **Раздел «Завершение»** — то, по чему видно, что цель достигнута. +- **Раздел «Завершение» — списком, а не абзацем.** Это признаки того, что + приложение уже умеет; **на строку «Завершения» ссылается задача**, объясняя, + какую часть возможности она двигает (см. тест готовности). Абзацем такая + ссылка не берётся, поэтому список. - **Тег `decomposed`** отличает «цель ещё не разобрана» от «все её задачи закрыты» — два состояния, у которых снаружи один и тот же признак: задач нет. Пометка именно **тегом**, а не строкой в теле: только так она проверяется. @@ -178,6 +186,12 @@ законна и зелёного прогона не ломает; `check --fix` сам ставит его цели, у которой задачи есть, а цель с тегом и без задач — прямое приглашение закрыть. - Цель живёт в `ROADMAP.md` и **никогда** — в `BACKLOG.md` или `SPRINT.md`. +- **Достигнутая цель не исчезает.** `close <слаг> --implemented` удаляет файл и + переносит строку в секцию `умеет` с датой: + `- 2026-08-04 \`merge-order\` — Исход слияния не зависит от порядка доставки. …` + Ссылки на файл в ней нет — файл удалён, а битая ссылка это ошибка `check`. + Поведение живёт в спеках проекта; роадмап отвечает, **когда и в каком порядке** + оно появилось. ## Слаг @@ -205,7 +219,7 @@ | Файл | Что отвечает | Секции | | --- | --- | --- | -| `ROADMAP.md` | какие есть цели, в какой очереди идут и почему | порядок (очередь значима) и темы (порядка нет) | +| `ROADMAP.md` | что приложение уже умеет и чего ещё не умеет | `умеет` (достигнутое), `строим` (очередь значима), `направления` (очереди нет), `станок` (не про приложение) | | `BACKLOG.md` | что **можно взять** — только задачи | секции проекта (по умолчанию ядро/инфра) | | `SPRINT.md` | какая цель и какой набор под неё | одна: «Набор» | | `REJECTED.md` | что ушло без реализации и почему | — | @@ -225,8 +239,11 @@ Постоянно пустая секция со старой семантикой «разбираются пачками» противоречила бы правилу «эскалируем немедленно», поэтому `init` её не заводит, а `check` говорит о ней в чужом беклоге. Переезжаешь с такой секцией — удали её. В секции -**«порядок»** очередь значима и обосновывается прозой; двигают строку -`move --section порядок --after <другой>`. +**`строим`** очередь значима и обосновывается прозой; двигают строку +`move --section строим --after <другой>`. В секции **`умеет`** строки +не той формы, что у прочих индексов: дата, слаг, заголовок — как в +`REJECTED.md`, и по той же причине (файла уже нет, ссылаться некуда). Имена +секций временные, см. SKILL.md. Индексы **производны**: расходятся с файлом — правим индексы (`check --fix`). Строку руками не пишут. @@ -265,11 +282,13 @@ Единственный механизм разметки, потому что `list --tag` уже умеет отбирать по ним порцию разбора. Отдельных полей меты под это не заводим. -- `goal:<слаг>` — цель, которой служит задача. Обязателен: задача без цели не - попадёт ни в один спринт. +- `goal:<слаг>` — цель, которой служит задача. Обязателен **у `kind:feature`**: + новая возможность и есть содержание цели. У `fix`, `chore` и `research` его + может не быть — они служат работоспособности, а не направлению, и в набор + спринта входят помимо его цели. - `kind:<род>` — род работы: `feature` | `fix` | `chore` | `research`. Словарь **закрыт**, значение ровно одно. Обязателен у задачи (без него `sprint take` - откажет), у цели запрещён, у идеи и эпика необязателен. Ставится + откажет), у цели запрещён, у идеи необязателен. Ставится `add --kind` / `edit --kind`; `--kind` заменяет прежнее значение, а не добавляет второе. Смысл рода и почему он тегом, а не префиксом — в SKILL.md, раздел «Род работы». @@ -303,15 +322,25 @@ 2. **Каких границ это касается** — раздел «Затрагивает». Без него задачу нельзя оценить: остаётся судить по длине текста. 3. **По чему видно, что закончено** — критерии приёмки с оракулами. -4. **Какой цели она служит** — тег `goal:` и одна строка «почему именно этой». +4. **Какую часть «Завершения» своей цели она двигает** — у задачи с целью. + Строкой: «двигает пункт 2 «Завершения» — накопительная метрика перестаёт + уменьшаться». Это и есть защита от задачи «отрефакторить X»: она проваливает + тест не потому, что невидима снаружи, а потому, что не находит строки, к + которой относится. Заодно видно обратное — достаточен ли набор задач для + цели: строка «Завершения», к которой не относится ни одна задача, это + незакрытая часть возможности. + + **У задачи без цели** (`fix`, `chore`, `research`) вопрос не задаётся: они + служат работоспособности, а не направлению. Не отвечается первый, второй или третий вопрос → это **идея** (`[idea]`), её -место в штурме. Не отвечается четвёртый → либо цель есть и не проставлена, либо -задача не служит ничему — тогда её не надо заводить. +место в штурме. Не отвечается четвёртый у `feature` → либо цель есть и не +проставлена, либо это не новая возможность. Отвечается всё, но задача не делается одним заходом и не мерджится целиком → -**эпик** (`[epic]`), сперва декомпозиция. Эпик временен и исчезает после -разбора; цель (`[goal]`) постоянна — не путать. +это **несколько задач под одной целью**, дроби сразу. Промежуточного зонтика +между целью и задачей нет: тип `[epic]` упразднён, потому что зонтиком стала +сама цель. Тест применяется при заведении и при переоценке. К старым задачам, которых операция не касается, задним числом не применяется — беклог не переоформляют diff --git a/av-dev-pm/skills/tasks/scripts/tasks.py b/av-dev-pm/skills/tasks/scripts/tasks.py index 7243612..fbceadd 100755 --- a/av-dev-pm/skills/tasks/scripts/tasks.py +++ b/av-dev-pm/skills/tasks/scripts/tasks.py @@ -12,7 +12,7 @@ av-dev, и подгоняется под него проект. Имена вн docs/tasks/ items/ задачи и цели файлами, .md - ROADMAP.md оглавление целей: порядок (значим) и темы (без порядка) + ROADMAP.md состояние проекта: что уже умеет и чего ещё не умеет BACKLOG.md что можно взять — только задачи, целей здесь нет SPRINT.md текущий спринт: цель, набор, дата, слаг REJECTED.md ушедшее БЕЗ реализации, с причиной и датой @@ -24,13 +24,20 @@ av-dev, и подгоняется под него проект. Имена вн задача — знают индексы**, потому что «в спринте» это свойство спринта, а не задачи; поля-состояния в файле нет, а рассогласование ловит check. -Тип — ключевое слово (goal | idea | epic | task), по-английски, как и прочие -токены команд. Обычная задача (task) префикса не несёт, остальные кодируются -префиксом `[goal]`/`[idea]`/`[epic]` в заголовке H1. Отдельного поля типа нет. +Тип — ключевое слово (goal | idea | task), по-английски, как и прочие токены +команд. Обычная задача (task) префикса не несёт, остальные кодируются префиксом +`[goal]`/`[idea]` в заголовке H1. Отдельного поля типа нет. -`[goal]` и `[epic]` — разные вещи: цель постоянна и живёт, пока живёт -направление; эпик временен — это задача, которая не мерджится целиком, её -разбирают, и он исчезает. +**Цель — возможность приложения**, а не тема работ: она отвечает на вопрос «что +приложение будет уметь», её «Завершение» — наблюдаемый признак того, что уже +умеет. Задача — шаг к этой возможности, она отвечает на «что для этого нужно +сделать». Отсюда роадмап и есть состояние проекта: достигнутая цель не +исчезает, а переезжает строкой с датой в секцию достигнутого. + +**Цель обязательна не у всякой задачи.** Новая возможность (`kind:feature`) без +цели не бывает — цель и есть её содержание. Починка, техдолг и разведка служат +работоспособности, а не направлению, и живут без цели законно; в набор спринта +они входят помимо его цели. **Род работы — вторая ось, и она отвечает на другой вопрос.** Тип записи говорит, что это за запись; род (`feature` | `fix` | `chore` | `research`, тегом @@ -44,7 +51,7 @@ av-dev, и подгоняется под него проект. Имена вн 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|epic] [--section S] + 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] [--add-tag a,b] [--rm-tag c,d] [--section S] [--dir DIR] @@ -107,6 +114,9 @@ DEFAULTS = { "sprint": "SPRINT.md", "rejected": "REJECTED.md", "sprint_section": "Набор", + # Секция роадмапа, куда переезжает достигнутая цель. Скрипт обязан знать её + # имя: остальные секции он берёт из заголовков как есть, а в эту пишет сам. + "achieved_section": "умеет", "criteria_heading": "Критерии приёмки", "surface_heading": "Затрагивает", "questions_heading": "Вопросы", @@ -118,7 +128,7 @@ DEFAULTS = { PATH_KEYS = ("items", "backlog", "roadmap", "sprint", "rejected") DEFAULT_SECTIONS = "ядро,инфра" -DEFAULT_ROADMAP_SECTIONS = "порядок,темы" +DEFAULT_ROADMAP_SECTIONS = "умеет,строим,направления,станок" # Мета — список под заголовком, поле на строку. Старая форма (все поля одной # строкой через `·`) читается по-прежнему: у проектов на диске лежат файлы в @@ -179,7 +189,7 @@ BULLET = re.compile(r"^[-*]\s+(.*)$") REJECTED_ENTRY = re.compile(r"^- \d{4}-\d{2}-\d{2} `[a-z0-9-]+` — .+") GOAL = "goal" -TYPES = ("goal", "idea", "epic") # непустые типы-ключевые слова, префикс [..] в H1 +TYPES = ("goal", "idea") # непустые типы-ключевые слова, префикс [..] в H1 PLAIN_TYPE = "task" # обычная задача — без префикса TAKEABLE = (PLAIN_TYPE,) # что вообще можно взять в спринт QUESTION_TAG = "question" @@ -192,6 +202,10 @@ SPRINT_TAG = "sprint:" # есть единственный механизм разметки, а `list --tag` уже умеет отбирать. KIND_TAG = "kind:" KINDS = ("feature", "fix", "chore", "research") +# Цель обязательна только у новой возможности: цель и есть возможность. +# Починка, техдолг и разведка служат работоспособности, а не направлению — +# придуманная им цель это то же враньё, от которого спасает род работы. +NEEDS_GOAL = ("feature",) DECOMPOSED_TAG = "decomposed" # цель разложена на задачи (см. «Статус цели») STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check CRITERIA_MIN, CRITERIA_MAX = 2, 5 # сколько утверждений в критериях приёмки @@ -841,9 +855,13 @@ def check(lay: Layout, fix: bool = False) -> int: if task["type"] == "idea": notes.append(f"{name}: идея без цели — цель проставляется," f" когда идея становится задачей") - else: - errors.append(f"{name}: нет тега {GOAL_TAG}<слаг> — задача вне цели" - f" не попадёт ни в один спринт") + elif task["kind"] in NEEDS_GOAL: + errors.append(f"{name}: род «{task['kind']}» без тега" + f" {GOAL_TAG}<слаг> — новая возможность и есть" + f" содержание цели. Либо цель заводится, либо" + f" это не {task['kind']}") + # Операционная задача (fix, chore, research) живёт без цели + # законно: она служит работоспособности, а не направлению. elif task["goal"] not in goal_slugs: errors.append(f"{name}: тег {GOAL_TAG}{task['goal']} указывает на цель," f" которой нет в {lay.cfg['items']}/") @@ -1053,7 +1071,7 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int: kind = "" if a.kind else f"{t['kind']:<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']:<8}{kind}{flag} " + print(f"{touched}{t['place']:<8}{t['section']:<12}{kind}{flag} " f"{t['path'].stem:<44} {rtype}{t['bare']}{goal}") print(f"\nвсего: {len(rows)}") @@ -1253,9 +1271,10 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int: elif not rtype: print(f" без рода работы — проставь `tasks.py edit {a.slug} --kind" f" {'|'.join(KINDS)}`: в спринт без него не возьмут") - if rtype != GOAL and not any(t.startswith(GOAL_TAG) for t in tags): - print(" без цели: задача вне цели не попадёт ни в один спринт —" - f" проставь `tasks.py edit {a.slug} --goal <слаг>`") + 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}» без цели: новая возможность и есть содержание" + f" цели — проставь `tasks.py edit {a.slug} --goal <слаг>`") # Урожай спринта метится сам: тег, который никто не ставит, не отбирает # первую порцию переоценки, а именно на ней держится правило «сперва урожай». sslug = sprint_slug(lay) @@ -1455,6 +1474,23 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int: return EXIT_OK +def is_achieved(task: dict, a: argparse.Namespace) -> bool: + """Цель, закрытая как достигнутая: `close --implemented` без причины.""" + return task["type"] == GOAL and not a.reason + + +def achieved_entry(task: dict, slug: str, date: str) -> str: + """Строка секции достигнутого. Ссылки на файл в ней нет намеренно: файл + удаляется, а битую ссылку `check` справедливо назовёт ошибкой. Поведение + живёт в спеках проекта — здесь остаётся «что и когда стало возможно».""" + why = task["why"] + tail = f" {why[0].upper()}{why[1:]}" if why else "" + if tail and not tail.rstrip().endswith((".", "!", "?")): + tail = tail.rstrip() + "." + dot = "" if task["bare"].endswith((".", "!", "?")) else "." + return f"- {date} `{slug}` — {task['bare']}{dot}{tail}" + + def cmd_close(lay: Layout, a: argparse.Namespace) -> int: for err in (bad_slug(a.slug), bad_reason(a.reason)): if err: @@ -1474,24 +1510,40 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int: f" Цель закрыта, когда не осталось её задач") plan = Plan() + today = datetime.date.today().isoformat() if a.reason: reason = a.reason.rstrip() dot = "" if reason.endswith((".", "!", "?")) else "." - date = datetime.date.today().isoformat() - bullet = (f"- {date} `{a.slug}` — {task['title']}. Причина: {reason}{dot}" + bullet = (f"- {today} `{a.slug}` — {task['title']}. Причина: {reason}{dot}" f" Была секция: {task['section'] or '—'}.") rej = lay.index("rejected") prev = rej.read_text(encoding="utf-8") if rej.exists() else "# Ушедшее без реализации\n" if not prev.endswith("\n"): prev += "\n" plan.file(rej, prev + bullet + "\n") + # Достигнутая цель — единственная запись, переживающая удаление файла без + # причины. Роадмап отвечает не только «что осталось», но и «что уже умеет», + # а этот ответ иначе стирался вместе с целью, и его вели прозой руками. + achieved = achieved_entry(task, a.slug, today) if is_achieved(task, a) else None for kind_index, (lines, ei) in places.items(): lines.pop(ei) + if achieved is not None and kind_index == "roadmap": + insert_entry(lines, lay.cfg["achieved_section"], achieved, first=True) + achieved = None plan.index(lay, kind_index, lines) + if achieved is not None: # строки в роадмапе не было — чиним + lines = read_lines(lay.index("roadmap")) + insert_entry(lines, lay.cfg["achieved_section"], achieved, first=True) + plan.index(lay, "roadmap", lines) plan.delete(path) plan.commit() - print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}") + if is_achieved(task, a): + print(f"{a.slug}: цель достигнута — строка перенесена в" + f" {lay.name('roadmap')}, секция «{lay.cfg['achieved_section']}»," + f" файл удалён") + else: + print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}") if "sprint" in places and a.reason: print(" задача закрыта прямо из спринта без реализации — назови это в докладе спринта") if not a.reason: @@ -1563,6 +1615,17 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: if target == "backlog" and goal_of_sprint and tmp["goal"] == goal_of_sprint: target, section = "sprint", lay.cfg["sprint_section"] lines = read_lines(lay.index(target)) + # Строка достигнутого снимается ДО вставки и на том же списке: иначе вторая + # правка читает индекс с диска, где первой ещё нет, и затирает её. + unachieved: list[str] = [] + if kind == GOAL: + kept = [] + for line in lines: + if REJECTED_ENTRY.match(line) and f"`{a.slug}`" in line: + unachieved.append(line) + else: + kept.append(line) + lines = kept if find_entry_index(lines, a.slug) is None: hi, sec = find_section(lines, section) if hi is None: @@ -1570,6 +1633,8 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: raise Usage(f"секции «{section}» нет в {lay.name(target)} (есть: {avail})") insert_entry(lines, sec, entry_line(lay, title, a.slug, tmp["why"])) plan.index(lay, target, lines) + elif unachieved: + plan.index(lay, target, lines) rej = lay.index("rejected") if rej.is_file(): @@ -1589,6 +1654,10 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: + (f" (секция «{section}»)" if target != "sprint" else " (набор спринта)")) for line in removed: print(f" снята строка {lay.name('rejected')}: {line.strip()}") + for line in unachieved: + print(f" снята строка «{lay.cfg['achieved_section']}» в" + f" {lay.name('roadmap')}: {line.strip()}") + print(" роадмап больше не утверждает, что приложение это умеет") print(" сверь тело: оно восстановлено на момент удаления, всё позднейшее" " живёт только в коммите задачи") return EXIT_OK @@ -1673,10 +1742,14 @@ def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int: t = parse_task(path) if t["type"] not in TAKEABLE: raise Usage(f"{slug}: тип «{t['type']}» в спринт не берётся —" - f" идея идёт на штурм, эпик на декомпозицию, цель не берут вовсе") - if t["goal"] != goal_slug: - raise Usage(f"{slug}: цель «{t['goal'] or '—'}» не цель спринта «{goal_slug}» —" + 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']}» без цели —" + f" новая возможность и есть содержание цели" + f" (`edit {slug} --goal <слаг>`)") # Отказ по факту, а не по метке: непустой раздел «Вопросы» блокирует # взятие независимо от тега. Забывший тег иначе проходил бы, а # поставивший спотыкался — стимул ровно обратный правилу. @@ -1987,10 +2060,17 @@ def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str], + "".join(f"## {s}\n\n" for s in sections)) out[lay.index("roadmap")] = ( "# Роадмап\n\n" - f"Оглавление целей. Цель — файл `[goal]` в `{lay.cfg['items']}/`; её задачи\n" - "здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.\n" - f"В первой секции («{roadmap_sections[0]}») очередь значима и обосновывается\n" - "прозой; в остальных порядка нет — это тематические цели.\n\n" + "Состояние проекта: что приложение **уже умеет** и чего ещё не умеет.\n" + f"Цель — возможность приложения, файл `[goal]` в `{lay.cfg['items']}/`; её\n" + "задачи здесь **не перечисляются** — перечень даёт\n" + "`tasks.py list --goal <слаг>`.\n\n" + f"- **{lay.cfg['achieved_section']}** — достигнутое: строку пишет\n" + " `tasks.py close <цель> --implemented`, ссылки на файл в ней нет —\n" + " файл удаляется, поведение живёт в спеках;\n" + "- **строим** — очередь значима и обосновывается прозой;\n" + "- **направления** — очереди нет, тянутся долго;\n" + "- **станок** — инструмент и процесс разработки, не возможности\n" + " приложения. Отдельно, чтобы не смешиваться с ними.\n\n" + "".join(f"## {s}\n\n" for s in roadmap_sections)) out[lay.index("sprint")] = empty_sprint(lay) out[lay.index("rejected")] = ( @@ -2180,6 +2260,17 @@ def scan_list_file(path: Path) -> dict: return found +def first_open_section(raw: str) -> str: + """Секция, куда adopt кладёт выведенные цели: первая **не** достигнутая. + + Первой в роадмапе идёт секция достигнутого, и класть в неё цель, выведенную + из шага плана, значит объявить сделанным то, что ещё не начато. + """ + done = DEFAULTS["achieved_section"].lower() + names = [s.strip() for s in raw.split(",") if s.strip()] + return next((s for s in names if s.lower() != done), "строим") + + def cmd_adopt_scan(a: argparse.Namespace) -> int: sources = [Path(s) for s in a.sources] for s in sources: @@ -2195,7 +2286,7 @@ def cmd_adopt_scan(a: argparse.Namespace) -> int: rejected += sc.get("rejected", []) unclassified += sc.get("unclassified", []) goals += [{"slug": "", "title": g["title"], - "section": (a.roadmap_sections.split(",")[0].strip() or "порядок"), + "section": first_open_section(a.roadmap_sections), "from": g["from"], "step": g.get("step"), "done": g.get("done"), "body": f"Выведена из шага «{g['title']}» ({g['from']})." + ("\n\nШаг помечен закрытым — цель, скорее всего,"