diff --git a/DECISIONS.md b/DECISIONS.md index 8a2ff6b..4af2b8c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2661,3 +2661,49 @@ JJJ): у профиля обязан быть один правильный от Читателей документа не перечисляет ни одна сторона — читатель назначается планом прогона. Прежняя ссылка на «таблицу читателей» пережила саму таблицу и обещала то, чего нет, — с той самой правки, которая таблицу и убрала. + +## 39. Спринт без цели — законный случай (2026-08-07) + +Цель была обязательной: `sprint start --goal` требовал слаг, `check` считал +ошибкой набор без названной цели, `sprint take` отказывал задаче под чужой +целью. Модель описывала только спринт развития — а спринт бывает под багфикс, +под техдолг, под здоровье проекта. Такой набор собран **по работоспособности, а +не по направлению**, и цели у него нет не по недосмотру. + +Обходной путь существовал и был хуже прямого: завести цель-пустышку («Здоровье +проекта») и вешать под неё `fix`-и. Тогда `ROADMAP.md` — документ про то, что +приложение умеет, — обрастает строками про то, что оно не ломается, а тег +`goal:` перестаёт значить направление. + +**АЕААА. Цель у спринта необязательна, но её отсутствие — ответ, а не молчание.** +`sprint start` принимает `--goal <слаг>` **или** `--no-goal`, и голое отсутствие +обоих — отказ с объяснением. Причина в стимуле: цель называет человек, и это +единственный продуктовый вопрос всей сессии. Разреши мы заводить спринт просто +без флага — забытый флаг, лень спросить и осознанное решение стали бы неотличимы +на выходе, а дешевле всего из трёх агенту именно не спрашивать. + +**АЕААБ. В спринте без цели цель не проверяется вовсе.** Набор берёт что угодно +готовое к взятию, включая задачи под разными целями: сверять не с чем. Правило +«набор служит одной цели» не ослаблено, оно просто не применяется — целей в +таком наборе не больше одной, их ноль. Взамен машинной проверки остаётся показ +набора человеку до заморозки: у бесцельного спринта это **единственная** +проверка состава, и в скилле это сказано прямо. + +**АЕААВ. Признак «спринт идёт» — слаг, а не цель.** Прежде код спрашивал цель и +получал заодно ответ про то, открыт ли спринт; теперь эти вопросы разошлись. +Слаг подходит на роль признака лучше цели по существу: он есть у любого спринта, +потому что без него нечем проставить `sprint:<слаг>`, то есть нечем собрать +урожай. Поле «Цель» в шапке остаётся на месте и у бесцельного набора — пишется +прозой без ссылки: **«цели нет» и «цель потерялась» обязаны различаться**. + +### Что из этого следует + +145. **Необязательное поле, которое всё же решают, заводится парой «значение или + явный отказ».** Умолчанием тут был бы не выбор, а его отсутствие — и + отличить его от забывчивости уже не смог бы никто, включая автора. +146. **Признак «сущность существует» нельзя вешать на её необязательное поле.** + Пока цель была обязательной, `sprint_goal()` отвечал сразу на два вопроса, + и это работало ровно до тех пор, пока второй ответ не понадобился отдельно. +147. **Снятая проверка называет, что осталось вместо неё.** Цель не проверяется + — значит, за состав отвечают показ человеку и строка доклада; иначе + послабление читается как «здесь можно не думать». diff --git a/av-dev-pm/skills/session/SKILL.md b/av-dev-pm/skills/session/SKILL.md index e642bbf..81d59fd 100644 --- a/av-dev-pm/skills/session/SKILL.md +++ b/av-dev-pm/skills/session/SKILL.md @@ -1,12 +1,12 @@ --- name: session -description: "Ритуал между спринтами и ведение самого спринта: разбор накопившихся вопросов, разбор прошедшего спринта про процесс, переоценка задач порциями, выбор цели и набор нового спринта с заморозкой. Плюс правила по ходу спринта — что врывается в замороженный набор, чем вопрос отличается от блокера, когда задача выходит из спринта, что считается сделанным и что идёт в доклад. Использовать, когда просят закрыть или начать спринт, собрать набор, разобрать вопросы, провести груминг/переоценку/ретроспективу, решить «что делать дальше» или доложить итоги, а также когда вернулись к проекту после перерыва и надо понять, где остановились. Формат и содержимое задач — скилл tasks." +description: "Ритуал между спринтами и ведение самого спринта: разбор накопившихся вопросов, разбор прошедшего спринта про процесс, переоценка задач порциями, выбор цели (или решение, что спринт без цели) и набор нового спринта с заморозкой. Плюс правила по ходу спринта — что врывается в замороженный набор, чем вопрос отличается от блокера, когда задача выходит из спринта, что считается сделанным и что идёт в доклад. Использовать, когда просят закрыть или начать спринт, собрать набор, разобрать вопросы, провести груминг/переоценку/ретроспективу, решить «что делать дальше» или доложить итоги, а также когда вернулись к проекту после перерыва и надо понять, где остановились. Формат и содержимое задач — скилл tasks." --- # Сессия между спринтами -Работа идёт спринтами: **набор задач под одну цель, замороженный до конца -спринта**. Между спринтами — одна сессия из четырёх шагов. Этот скилл владеет +Работа идёт спринтами: **набор задач, замороженный до конца спринта** — обычно +под одну цель, но бывает и без неё. Между спринтами — одна сессия из четырёх шагов. Этот скилл владеет **ритуалом**: как сессия проводится и как спринт ведётся. Форматом и содержимым задач владеет скилл `tasks`, выполнением задачи — пайплайн проекта. @@ -28,8 +28,8 @@ description: "Ритуал между спринтами и ведение са ## Роли -**Человек** выбирает цель спринта, разбирает вопросы, держит право на -необратимое и на истину в самих данных. +**Человек** выбирает цель спринта — **или решает, что этот спринт без цели**, — +разбирает вопросы, держит право на необратимое и на истину в самих данных. **Агент — оркестрация.** Он собирает набор под названную цель, ставит задачи, принимает отчёты и докладывает. Кто именно делает задачу — исполнитель, сабагент, @@ -47,7 +47,10 @@ description: "Ритуал между спринтами и ведение са `question`. - **Блокер** — состояние, когда спринт не может продолжаться **ни одной** задачей. -- **Спринт** — набор задач под одну цель, замороженный до его конца. +- **Спринт** — набор задач, замороженный до его конца. Под одной целью — или + **без цели вовсе**, законно: багфикс, техдолг, спринт здоровья. Такой набор + собран по работоспособности, а не по направлению, и заводится явно + (`sprint start --no-goal`). ## Вопрос, блокер, необратимое @@ -88,9 +91,17 @@ description: "Ритуал между спринтами и ведение са ## Заморозка набора -**Цель одна.** Набор служит ей; задача, не служащая цели, в спринт не попадает, -даже если взять удобно (`sprint take` это и запрещает). **Задача с открытым -вопросом в набор не берётся.** +**Целей не больше одной.** Названа цель — набор служит ей: задача под чужой +целью в спринт не попадает, даже если взять удобно (`sprint take` это и +запрещает). **Задача с открытым вопросом в набор не берётся** — это верно всегда. + +**Спринт без цели — законный случай, а не недосмотр.** Багфикс, техдолг, +здоровье: работа на работоспособность, а не на направление. Цель не названа — +сверять нечего, и в такой набор идёт что угодно готовое к взятию, в том числе +задачи под разными целями. Заводится он **явно**, `sprint start --no-goal`: +забытый флаг и решение человека иначе неотличимы, а это решение продуктовое. +Взамен проверки цели остаётся доклад — спринт без цели **называется таковым и +объясняется** одной строкой. **Новая работа падает в беклог, а не в идущий спринт.** Решение «врываться или отложить» принимается один раз правилом, а не заново каждый раз. Врывается @@ -122,8 +133,8 @@ description: "Ритуал между спринтами и ведение са судьи документов канона на весь канон разом, раз в спринт: `doc-consistency` (документы между собой) и `doc-code-drift` (документы против кода). 3. **Переоценка задач** порциями. -4. **Выбор цели и набор спринта.** Цель называет человек, набор собирает агент и - показывает **до старта работ**. +4. **Выбор цели и набор спринта.** Цель называет человек — либо называет, что + этот спринт без цели; набор собирает агент и показывает **до старта работ**. Рёбра подписаны тем, что ломается при их нарушении: @@ -155,7 +166,7 @@ flowchart TD 1. `tasks.py check` — блок здоровья скажет состояние спринта, число готовых к взятию и залежавшихся; при расхождении раскладки `--fix`. -2. Прочитать `SPRINT.md`: цель, состав, дата начала. +2. Прочитать `SPRINT.md`: цель (или запись, что её нет), состав, дата начала. 3. **Развилка, и решает её человек.** Набор всё ещё твой — продолжай спринт, ни сессии, ни переоценки не нужно, они между спринтами. Взялся перечитывать, зачем эти задачи собраны вместе, — набор протух: @@ -183,6 +194,7 @@ python3 $tk list --dir D --tag sprint:<слаг> # шаг 3: урожай с python3 $tk list --dir D --stale # шаг 3: дальше по залежалости python3 $tk list --dir D --goal <слаг> # шаг 4: кандидаты под названную цель python3 $tk sprint start --dir D --goal <слаг> # шаг 4: заводит и слаг спринта +python3 $tk sprint start --dir D --no-goal # шаг 4: набор без цели, явным флагом python3 $tk sprint take --dir D <слаг> … # шаг 4: набор python3 $tk sprint close --dir D # конец спринта; --dissolve при блокере python3 $tk reopen <слаг> --dir D --reason … # приёмка не сошлась после закрытия @@ -239,6 +251,11 @@ python3 $tk reopen <слаг> --dir D --reason … # приёмка не со - **Сжать задачу до остатка** и отчитаться «сделана». Защита ослаблена там же. Пол для остатка — польза, названная в «зачем»; проверяет его человек при приёмке, и `reopen` — его инструмент. +- **Объявить спринт без цели**, чтобы не задавать человеку продуктовый вопрос: + набор без цели берёт что угодно, и собрать его можно молча. Защита: цели нет + — это **ответ человека, а не умолчание** (`--no-goal` спрашивается так же, как + цель), плюс строка доклада, называющая спринт бесцельным и объясняющая почему. + Два бесцельных спринта подряд — предмет разбора процесса, а не мелочь. - **Занизить урожай** — не заводить найденное по ходу. Защита: поимённая сверка с **сохранённым независимым отчётом**, а не с прозой исполнителя. Каждая отложенная находка имеет либо слаг, либо строку «не заведена: причина». diff --git a/av-dev-pm/skills/session/references/cadence.md b/av-dev-pm/skills/session/references/cadence.md index 793f4bd..e93c6d7 100644 --- a/av-dev-pm/skills/session/references/cadence.md +++ b/av-dev-pm/skills/session/references/cadence.md @@ -225,19 +225,28 @@ по каждой цели-кандидату — сколько под ней задач без открытых вопросов (`list --goal <слаг>`). Цель без готовых задач набором не станет: её сперва надо декомпозировать. -2. **Цель называет человек.** Это продуктовое решение, а не механика: агент - предлагает и объясняет, но не выбирает. -3. **Набор собирает агент** — `sprint start --goal <слаг>`, затем `sprint take - …`. Скрипт не даст взять цель, задачу с чужой целью, с открытым вопросом, без - типа и **без разделов, которых требует её тип** (у `fix` это в том числе - `Воспроизведение`, у `research` — `Вопрос` и `Куда ляжет ответ`, и сырьё - поэтому не берётся вовсе). Задача без цели (`fix`, `chore`, `research`) - берётся свободно — операционная работа входит в набор помимо его цели. +2. **Цель называет человек** — либо называет, что цели не будет. Это + продуктовое решение, а не механика: агент предлагает и объясняет, но не + выбирает. **Оба ответа законны**, и «без цели» — такой же ответ, как слаг: + спринт бывает под багфикс, под техдолг, под здоровье проекта. Спрашивается он + так же, как цель, и в отдельный вопрос не выносится: это один и тот же вопрос + «подо что набираем». +3. **Набор собирает агент** — `sprint start --goal <слаг>` (или `sprint start + --no-goal`), затем `sprint take …`. Скрипт не даст взять цель, задачу с чужой + целью, с открытым вопросом, без типа и **без разделов, которых требует её + тип** (у `fix` это в том числе `Воспроизведение`, у `research` — `Вопрос` и + `Куда ляжет ответ`, и сырьё поэтому не берётся вовсе). Задача без цели (`fix`, + `chore`, `research`) берётся свободно — операционная работа входит в набор + помимо его цели. **В спринте без цели чужой цели нет вовсе**: сверять не с + чем, берётся что угодно готовое, и единственной защитой остаётся показ набора + человеку. 4. **Набор показывается человеку до старта работ.** Показ — это и есть момент заморозки: после него набор не двигается. **В показе называется состав по типам** — три `fix` и ни одной `feature` под целью развития это разговор про цель, а не про набор, и увидеть его надо до заморозки, а не в докладе по - итогам. + итогам. **У набора без цели показ — единственная проверка состава**: скрипту + там отказывать не по чему, и «что угодно готовое» превращается в осмысленный + набор только глазами человека. Здесь же последний дешёвый момент заметить **разнородную задачу**: раздел «Затрагивает» показывает границы до того, как заведено предложение об @@ -261,7 +270,8 @@ названного, что разошлось. - Изменения списком: удалено как реализованное (со ссылками), ушло без реализации (с причинами), понижено до сырья, слито, сменило тип или цель. -- Новый спринт: цель, набор со слагами, дата, состав по типам. +- Новый спринт: цель — **или строка «без цели» с объяснением, почему** (багфикс, + техдолг, здоровье), — набор со слагами, дата, состав по типам. - **Границы покрытия**: сколько задач не трогали и какие именно секции, теги или цели остались — иначе доклад читается как «беклог разобран». - `tasks.py check` после правок — результат строкой. diff --git a/av-dev-pm/skills/session/references/sprint.md b/av-dev-pm/skills/session/references/sprint.md index a34cabd..2249a12 100644 --- a/av-dev-pm/skills/session/references/sprint.md +++ b/av-dev-pm/skills/session/references/sprint.md @@ -1,7 +1,8 @@ # Ведение спринта -Спринт — набор задач под одну цель, замороженный до его конца. Здесь то, что -происходит **внутри** спринта: как задача заканчивается, что считается сделанным, +Спринт — набор задач, замороженный до его конца: под одну цель или **без цели** +(багфикс, техдолг, здоровье — это законно, `sprint start --no-goal`). Здесь то, +что происходит **внутри** спринта: как задача заканчивается, что считается сделанным, кто принимает и что идёт в доклад. Как спринт набирается — шаг 4 в [cadence.md](cadence.md). @@ -21,7 +22,7 @@ - **Оказалась крупнее задачи** — распознаётся **до того, как под неё заведено предложение об изменении**, иначе его придётся выбрасывать. Выходит из набора, уходит на декомпозицию; спринт продолжается остальными, части заводятся под той - же целью и в замороженный набор не добавляются. + же целью (у спринта без цели — без неё) и в замороженный набор не добавляются. - **Отменена решением по ходу** — `close --reason "<ссылка на решение>"` прямо из спринта. Это редкий, но законный исход, и он называется в докладе. @@ -170,9 +171,9 @@ flowchart TD Только два класса — правило и его обоснование в SKILL.md. Здесь механика: -- вторжение **не добавляет** задачу в набор: `SPRINT.md` остаётся набором под - цель. Внеплановая работа делается и называется в докладе отдельной строкой - «внеплановое: что и почему»; +- вторжение **не добавляет** задачу в набор: `SPRINT.md` остаётся тем набором, + который заморозили и показали. Внеплановая работа делается и называется в + докладе отдельной строкой «внеплановое: что и почему»; - если внеплановое требует больше пары часов, честнее распустить спринт, чем делать вид, что набор соблюдается; - всё остальное падает в беклог через обычный интейк и ждёт сессии. @@ -181,8 +182,10 @@ flowchart TD Проверяемые якоря, а не пересказ: -- **Цель спринта** и по каждой задаче набора: **хеш коммита**, дословный исход - проверок проекта, **исход по каждому критерию приёмки**. +- **Цель спринта** — или строка «спринт без цели» с тем, чем он был (багфикс, + техдолг, здоровье): у бесцельного набора это единственное место, где состав + вообще объясняется. И по каждой задаче набора: **хеш коммита**, дословный + исход проверок проекта, **исход по каждому критерию приёмки**. - **Какие развилки решались** и чем обоснованы. - **Урожай:** сколько задач заведено, какие вопросы накопились, что вышло из спринта и почему, что было внеплановым. diff --git a/av-dev-pm/skills/tasks/SKILL.md b/av-dev-pm/skills/tasks/SKILL.md index d2bfe85..5d9d9d0 100644 --- a/av-dev-pm/skills/tasks/SKILL.md +++ b/av-dev-pm/skills/tasks/SKILL.md @@ -69,7 +69,7 @@ docs/tasks/ items/ задачи и цели файлами, .md, слаги английские ROADMAP.md состояние проекта: что уже умеет и чего ещё не умеет BACKLOG.md что можно взять — только задачи, целей здесь нет - SPRINT.md текущий спринт: цель, набор, дата + SPRINT.md текущий спринт: цель (или её отсутствие), набор, дата REJECTED.md ушедшее БЕЗ реализации, с причиной и датой ``` @@ -369,7 +369,7 @@ 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 # просто удалить (реализована и закоммичена) python3 $tk reopen S --dir D --reason R # вернуть закрытую: приёмка не сошлась -python3 $tk sprint start --goal S --dir D | take S… | drop S… --reason R | close [--dissolve --reason R] +python3 $tk sprint start (--goal S | --no-goal) --dir D | take S… | drop S… --reason R | close [--dissolve --reason R] python3 $tk init --dir D [--sections …] [--items …] [--backlog …] … python3 $tk adopt scan --from … | apply --plan … # разовая адаптация, references/adopt.md ``` diff --git a/av-dev-pm/skills/tasks/references/task-format.md b/av-dev-pm/skills/tasks/references/task-format.md index 5012317..19b7ff0 100644 --- a/av-dev-pm/skills/tasks/references/task-format.md +++ b/av-dev-pm/skills/tasks/references/task-format.md @@ -278,12 +278,16 @@ | --- | --- | --- | | `ROADMAP.md` | что приложение уже умеет и чего ещё не умеет | канонические и в этом порядке: `Запланировано`, `Направления`, `Сопровождение`, `Готово` (англ. `Planned`, `Directions`, `Operations`, `Done`) | | `BACKLOG.md` | что **можно взять** — только задачи | категории проекта (по умолчанию Ядро/Инфра) | -| `SPRINT.md` | какая цель и какой набор под неё | одна: «Набор» | +| `SPRINT.md` | какая цель (или что её нет) и какой набор заморожен | одна: «Набор» | | `REJECTED.md` | что ушло без реализации и почему | — | Шапку `SPRINT.md` пишет `sprint start` — **тем же мета-блоком, что у задачи**: поле на строку, `- **Цель:** [Заголовок](items/slug.md)`, `- **Начат:**` датой, -`- **Спринт:**` слагом, которым метится урожай. Прежняя форма (три поля одной +`- **Спринт:**` слагом, которым метится урожай. У спринта без цели +(`sprint start --no-goal`) поле «Цель» остаётся на месте и пишется прозой без +ссылки — «не названа»: **«цели нет» и «цель потерялась» обязаны различаться**. +Поэтому и признак «спринт идёт» — слаг, а не цель: слаг есть у любого спринта, +без него нечем метить урожай. Прежняя форма (три поля одной строкой через `·`) читается по-прежнему и уходит сама: файл переписывается на следующем `sprint start` и очищается на `sprint close`. diff --git a/av-dev-pm/skills/tasks/scripts/tasks.py b/av-dev-pm/skills/tasks/scripts/tasks.py index 9076f7a..e301425 100755 --- a/av-dev-pm/skills/tasks/scripts/tasks.py +++ b/av-dev-pm/skills/tasks/scripts/tasks.py @@ -3,8 +3,8 @@ Преемник backlog.py. Разница по существу одна: **порядка нет, есть цель**. Приоритеты и секция-как-уровень заменены на цель (`goal:<слаг>` тегом) и на -спринт — замороженный набор задач под одну цель. Индексов теперь четыре, и -задача живёт ровно в одном из них за раз. +спринт — замороженный набор задач, обычно под одну цель. Индексов теперь +четыре, и задача живёт ровно в одном из них за раз. Раскладка. Путь каталога — `docs/tasks`, жёстко: это часть канона документов av-dev, и подгоняется под него проект. Имена внутри настраиваются через @@ -17,7 +17,7 @@ av-dev, и подгоняется под него проект. Имена вн Направления | Сопровождение | Готово (или Planned | Directions | Operations | Done — один язык на индекс) BACKLOG.md что можно взять — только задачи, целей здесь нет - SPRINT.md текущий спринт: цель, набор, дата, слаг + SPRINT.md текущий спринт: цель (или её отсутствие), набор, дата, слаг REJECTED.md ушедшее БЕЗ реализации, с причиной и датой Источник истины — файл задачи в items/. Индексы производны: расходятся — @@ -68,7 +68,7 @@ goal | feature | fix | chore | research, по-английски, как и пр tasks.py move S --section S [--reason R] [--after S | --first] [--dir DIR] tasks.py close S (--reason R | --implemented) [--dir DIR] tasks.py reopen S [--reason R] [--dir DIR] - tasks.py sprint start --goal S [--date ГГГГ-ММ-ДД] [--slug S] [--dir DIR] + tasks.py sprint start (--goal S | --no-goal) [--date ГГГГ-ММ-ДД] [--slug S] [--dir DIR] tasks.py sprint take S [S …] [--dir DIR] tasks.py sprint drop S [S …] --reason R [--dir DIR] tasks.py sprint close [--dissolve --reason R] [--dir DIR] @@ -996,7 +996,11 @@ def questions_open(lay: Layout, task: dict) -> bool: # --- Спринт --- def sprint_goal(lay: Layout) -> tuple[str, str]: - """Слаг и заголовок цели текущего спринта; ('', '') — спринта нет.""" + """Слаг и заголовок цели текущего спринта; ('', '') — цели нет. + + Пустой ответ значит «цель не названа», а НЕ «спринта нет»: спринт бывает + без цели законно (`--no-goal`). Идёт ли спринт — отвечает `sprint_started`. + """ for line in read_lines(lay.index("sprint")): if (m := GOAL_LINE.match(line.strip())): return Path(m.group(2)).stem, m.group(1) @@ -1011,6 +1015,16 @@ def sprint_slug(lay: Layout) -> str: return "" +def sprint_started(lay: Layout) -> bool: + """Идёт ли спринт. Признак — слаг, а не цель: цели может не быть. + + Слаг пишет `sprint start` и стирает `sprint close`, он есть у любого + спринта — без него не проставить `sprint:<слаг>`, то есть не собрать + урожай. Поэтому он и есть наблюдаемое «спринт открыт». + """ + return bool(sprint_slug(lay)) + + def sprint_header(lay: Layout, goal_slug: str, goal_title: str, date: str, slug: str) -> list[str]: """Шапка спринта — мета-блок той же формы, что у задачи: поле на строку. @@ -1019,10 +1033,15 @@ def sprint_header(lay: Layout, goal_slug: str, goal_title: str, — обе регулярки её берут, — но чинить её нечем и не нужно: `SPRINT.md` переписывается целиком на `sprint start` и очищается на `sprint close`, так что старая шапка живёт не дольше идущего спринта. + + Спринт без цели пишет ту же строку прозой, без ссылки: поле остаётся на + месте, читатель видит решение, а `GOAL_LINE` его не берёт — «цель не + названа» и «цель потерялась» этим и различаются. """ - link = f"{lay.cfg['items']}/{goal_slug}.md" + goal_line = (f"- **Цель:** [{goal_title}]({lay.cfg['items']}/{goal_slug}.md)" + if goal_slug else "- **Цель:** не названа — набор без цели") return ["# Спринт", "", - f"- **Цель:** [{goal_title}]({link})", + goal_line, f"- **Начат:** {date}", f"- **Спринт:** `{slug}`", "", f"Урожай спринта поднимается `tasks.py list --tag {SPRINT_TAG}{slug}`" @@ -1032,7 +1051,9 @@ def sprint_header(lay: Layout, goal_slug: str, goal_title: str, def empty_sprint(lay: Layout) -> str: return ("# Спринт\n\n" "Спринта нет. Цель называет человек, набор собирает агент:\n" - "`tasks.py sprint start --goal <слаг>`.\n\n" + "`tasks.py sprint start --goal <слаг>`. Набор без цели —" + " `sprint start --no-goal`\n(багфикс, техдолг, здоровье:" + " работа на работоспособность, а не на направление).\n\n" f"## {lay.cfg['sprint_section']}\n") @@ -1215,6 +1236,8 @@ def check(lay: Layout, fix: bool = False) -> int: if task["type"] and task["type"] not in TAKEABLE: errors.append(f"{name}: в спринте тип «{task['type']}»" f" — цель не берут вовсе, её берут её задачи") + # Спринт без цели ничьей цели и не противоречит: проверять нечему, + # набор там собран по другому признаку (работоспособность). if goal_of_sprint and task["goal"] and task["goal"] != goal_of_sprint: errors.append(f"{name}: цель задачи «{task['goal']}» не цель спринта" f" «{goal_of_sprint}» — набор служит одной цели") @@ -1267,11 +1290,18 @@ def check(lay: Layout, fix: bool = False) -> int: if goal_of_sprint and goal_of_sprint + ".md" not in tasks: errors.append(f"{label['sprint']}: цель «{goal_of_sprint}» не найдена" f" в {lay.cfg['items']}/") - if not goal_of_sprint and entries["sprint"]: - errors.append(f"{label['sprint']}: набор есть, а цель не названа") - if goal_of_sprint and not sprint_slug(lay): - errors.append(f"{label['sprint']}: у спринта нет слага (- **Спринт:** `…`) —" - f" урожай не отобрать; перезапусти `sprint start`") + # Спринт без цели законен (`sprint start --no-goal`), и по цели о том, идёт + # ли он, судить нельзя. Судим по слагу: он есть у любого спринта, потому + # что без него не проставить `sprint:<слаг>` и не собрать урожай. + if entries["sprint"] and not sprint_started(lay): + errors.append(f"{label['sprint']}: набор есть, а шапки спринта нет" + f" (- **Спринт:** `…`) — урожай не отобрать, а «спринт идёт»" + f" не отличить от «строки остались от прошлого»;" + f" перезапусти `sprint start`") + elif goal_of_sprint and not sprint_started(lay): + errors.append(f"{label['sprint']}: цель названа, а слага спринта нет" + f" (- **Спринт:** `…`) — урожай не отобрать;" + f" перезапусти `sprint start`") rejected = lay.index("rejected") if rejected.is_file(): @@ -1338,8 +1368,9 @@ def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None: " ответа на вопрос")) goal_slug, _ = sprint_goal(lay) - if goal_slug: - print(f" спринт: цель «{goal_slug}», слаг «{sprint_slug(lay) or '—'}»," + if sprint_started(lay): + goal_part = f"цель «{goal_slug}»" if goal_slug else "без цели" + print(f" спринт: {goal_part}, слаг «{sprint_slug(lay)}»," f" задач {len(entries['sprint'])}") else: print(" спринт: не начат") @@ -2140,7 +2171,11 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: goal_of_sprint, _ = sprint_goal(lay) target = home_index({"type": rtype}) section = tmp["section"] - if target == "backlog" and goal_of_sprint and tmp["goal"] == goal_of_sprint: + # Возврат идёт в набор идущего спринта — тем же тестом, что и `sprint take`: + # мешает только чужая цель. Спринт без цели не мешает ничем, и задача без + # цели не мешает спринту с целью. Спринта нет — возврат в беклог. + fits_sprint = not (goal_of_sprint and tmp["goal"] and tmp["goal"] != goal_of_sprint) + if target == "backlog" and sprint_started(lay) and fits_sprint: target, section = "sprint", lay.cfg["sprint_section"] lines = read_lines(lay.index(target)) # Строка достигнутого снимается ДО вставки и на том же списке: иначе вторая @@ -2210,19 +2245,29 @@ def meta_updated_text(text: str, reason: str, rtype: str | None = None) -> str | # --- Спринт --- def cmd_sprint_start(lay: Layout, a: argparse.Namespace) -> int: - if (err := bad_slug(a.goal)): + # Цель либо названа, либо явно не названа. Голое отсутствие `--goal` не + # проходит намеренно: забытый флаг и решение «этот спринт без цели» иначе + # неразличимы, а второе — продуктовое решение человека. + if not a.goal and not a.no_goal: + raise Usage("цель спринта: --goal <слаг> или явно --no-goal." + " Спринт без цели законен (багфикс, техдолг, здоровье)," + " но называется решением, а не пропуском флага") + if a.goal and (err := bad_slug(a.goal)): raise Usage(err) entries, _ = parse_entries(read_lines(lay.index("sprint"))) if entries: raise Usage(f"в {lay.name('sprint')} ещё есть набор ({len(entries)}) —" f" закрой спринт: tasks.py sprint close") - gpath = lay.items / f"{a.goal}.md" - if not gpath.exists(): - raise Usage(f"цели {a.goal}.md нет в {lay.cfg['items']}/") - goal = parse_task(gpath) - if goal["type"] != GOAL: - raise Usage(f"{a.goal} не цель (тип «{goal['type'] or '—'}») —" - f" спринт набирается под одну цель") + goal_title = "" + if a.goal: + gpath = lay.items / f"{a.goal}.md" + if not gpath.exists(): + raise Usage(f"цели {a.goal}.md нет в {lay.cfg['items']}/") + goal = parse_task(gpath) + if goal["type"] != GOAL: + raise Usage(f"{a.goal} не цель (тип «{goal['type'] or '—'}») —" + f" спринт набирается под одну цель") + goal_title = goal["title"] date = a.date or datetime.date.today().isoformat() if not DATE_RE.fullmatch(date): raise Usage("дата в формате ГГГГ-ММ-ДД") @@ -2236,17 +2281,27 @@ def cmd_sprint_start(lay: Layout, a: argparse.Namespace) -> int: if any(f"{SPRINT_TAG}{slug}" in t["tags"] for t in tasks.values()): print(f" внимание: тег {SPRINT_TAG}{slug} уже стоит на задачах прошлого спринта" f" — урожаи склеятся; задай другой `--slug`") - lines = [*sprint_header(lay, a.goal, goal["title"], date, slug), + lines = [*sprint_header(lay, a.goal or "", goal_title, date, slug), f"## {lay.cfg['sprint_section']}", ""] plan = Plan() plan.index(lay, "sprint", lines) plan.commit() ready = [n[:-3] for n, t in tasks.items() - if t["goal"] == a.goal and t["type"] in TAKEABLE + if t["type"] in TAKEABLE + and (t["goal"] == a.goal if a.goal + else not (t["type"] in NEEDS_GOAL and not t["goal"])) 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 "")) + if a.goal: + print(f"спринт начат: цель «{a.goal}», {date}, слаг «{slug}»") + print(f" кандидатов под цель без открытых вопросов: {len(ready)}" + + (f" ({', '.join(sorted(ready))})" if ready else "")) + else: + print(f"спринт начат: без цели, {date}, слаг «{slug}»") + print(f" набор без цели: цель не проверяется, в него идёт что угодно" + f" из готового к взятию ({len(ready)})." + f" Перечень — `tasks.py list --index backlog`") + print(" докладу это не безразлично: спринт без цели называется таковым" + " и объясняется (багфикс, техдолг, здоровье)") print(f" заводимое по ходу метится тегом {SPRINT_TAG}{slug} само — это урожай") print(" набери: tasks.py sprint take <слаг> …; набор показывается человеку до старта") return EXIT_OK @@ -2254,8 +2309,10 @@ def cmd_sprint_start(lay: Layout, a: argparse.Namespace) -> int: def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int: goal_slug, _ = sprint_goal(lay) - if not goal_slug: - raise Usage("спринт не начат: tasks.py sprint start --goal <слаг>") + # Начат ли спринт, судим по слагу, а не по цели: спринт без цели идёт так же. + if not sprint_started(lay): + raise Usage("спринт не начат:" + " tasks.py sprint start (--goal <слаг> | --no-goal)") sprint_lines = read_lines(lay.index("sprint")) backlog_lines = read_lines(lay.index("backlog")) hi, section = find_section(sprint_lines, lay.cfg["sprint_section"]) @@ -2277,7 +2334,10 @@ def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int: if t["type"] not in TAKEABLE: raise Usage(f"{slug}: тип «{t['type']}» в спринт не берётся —" f" цель не берут вовсе, берут её задачи") - if t["goal"] and t["goal"] != goal_slug: + # Цель сверяется, только когда она у спринта есть. Спринт без цели + # берёт что угодно готовое: он собран по работоспособности, и чужой + # цели там нет — не с чем расходиться. + if goal_slug and t["goal"] and t["goal"] != goal_slug: raise Usage(f"{slug}: цель «{t['goal']}» не цель спринта «{goal_slug}» —" f" набор служит одной цели, даже если взять удобно") if not t["goal"] and t["type"] in NEEDS_GOAL: @@ -2383,7 +2443,8 @@ def cmd_sprint_close(lay: Layout, a: argparse.Namespace) -> int: plan = Plan() plan.file(lay.index("sprint"), empty_sprint(lay)) plan.commit() - print(f"спринт закрыт (цель «{goal_slug or '—'}», слаг «{slug or '—'}»)" + print(f"спринт закрыт ({f'цель «{goal_slug}»' if goal_slug else 'без цели'}," + f" слаг «{slug or '—'}»)" + (", набор распущен" if a.dissolve and entries else "")) print(f" история наборов остаётся в git: `git log -p {lay.index('sprint')}`") if slug: @@ -3315,8 +3376,15 @@ def main() -> int: p = sub.add_parser("sprint", help="операции спринта") ssub = p.add_subparsers(dest="sprint_command", required=True) - s = ssub.add_parser("start", help="начать спринт под названную цель") - s.add_argument("--goal", required=True) + s = ssub.add_parser("start", help="начать спринт под названную цель или без цели") + # Группа взаимоисключающая, но не required: отказ за отсутствие обоих + # флагов даёт cmd_sprint_start — его текст объясняет, а argparse только + # называет флаги. + g = s.add_mutually_exclusive_group() + g.add_argument("--goal", help="слаг цели, под которую набирается спринт") + g.add_argument("--no-goal", dest="no_goal", action="store_true", + help="набор без цели: багфикс, техдолг, здоровье —" + " работа на работоспособность, а не на направление") s.add_argument("--date") s.add_argument("--slug", help="слаг спринта; по умолчанию дата начала") s.add_argument("--dir")