спринт без цели: цель стала необязательной, но не молчаливой
Цель была обязательной во всех трёх местах сразу: 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:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user