спринт без цели: цель стала необязательной, но не молчаливой

Цель была обязательной во всех трёх местах сразу: sprint start требовал слаг,
check считал ошибкой набор без названной цели, sprint take отказывал задаче под
чужой целью. Модель описывала только спринт развития — а спринт бывает под
багфикс, под техдолг, под здоровье проекта: такой набор собран по
работоспособности, а не по направлению, и цели у него нет не по недосмотру.

Обходной путь существовал и был хуже прямого: завести цель-пустышку «Здоровье
проекта» и вешать под неё fix-ы. Тогда ROADMAP.md — документ про то, что
приложение умеет, — обрастает строками про то, что оно не ломается, а тег goal:
перестаёт значить направление.

Теперь sprint start принимает --goal <слаг> ИЛИ --no-goal, и голое отсутствие
обоих — отказ с объяснением. Причина в стимуле: цель называет человек, и это
единственный продуктовый вопрос всей сессии. Разреши мы заводить спринт просто
без флага — забытый флаг, лень спросить и осознанное решение стали бы
неотличимы на выходе, а дешевле всего из трёх агенту именно не спрашивать. Тот
же обход записан в «Стимулы» скилла session вместе с защитой.

В спринте без цели цель не проверяется вовсе: набор берёт что угодно готовое к
взятию, включая задачи под разными целями. Правило «набор служит одной цели» не
ослаблено, оно просто не применяется — целей там не больше одной, их ноль.
Взамен машинной проверки остаётся показ набора человеку до заморозки, и в
cadence.md сказано прямо: у бесцельного спринта это единственная проверка
состава. Доклад обязан называть спринт бесцельным и объяснять, чем он был.

Признак «спринт идёт» разъехался с целью и переехал на слаг: слаг есть у любого
спринта, потому что без него нечем проставить sprint:<слаг>, то есть нечем
собрать урожай. На sprint_started() переведены sprint take, блок здоровья check
и reopen. Поле «Цель» в шапке остаётся и у бесцельного набора — пишется прозой
без ссылки: «цели нет» и «цель потерялась» обязаны различаться.

Побочно выправлен reopen: он возвращает задачу в набор идущего спринта тем же
тестом, что и sprint take (мешает только чужая цель). Прежде тест был уже —
совпадение целей, — и задача без цели, реопенутая при спринте с целью, уезжала
в беклог вопреки прозе скилла.

Тема 39 в DECISIONS.md, следствия 145-147.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-07 09:19:29 +03:00
co-authored by Claude Opus 5
parent 4386eb3e1c
commit 37394444e8
7 changed files with 217 additions and 69 deletions
+2 -2
View File
@@ -69,7 +69,7 @@ docs/tasks/
items/ задачи и цели файлами, <slug>.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
```
@@ -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`.
+103 -35
View File
@@ -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")