роадмап — состояние проекта, а не очередь работ

Основной инструмент владельца отвечал на половину своего вопроса. Оценка идёт
по поведению: что приложение уже может и чего ещё не может, — а close
--implemented удалял у достигнутой цели и файл, и строку, так что роадмап по
построению показывал только «что осталось». Свидетельство лежало в самом
роадмапе healthlog: секция «Что уже пройдено» на двадцать строк прозы, руками,
с припиской «Эти звенья целями не заведены: закрытая цель записи не оставляет».

Теперь строка с датой переезжает в секцию достигнутого, файл удаляется
по-прежнему. Вторым домом поведения это не делает: нормативное поведение живёт
в openspec/specs, роадмап отвечает, когда и в каком порядке оно появилось.
Ссылки на файл в строке нет — файла больше нет, форма как в REJECTED.md.

Цель стала возможностью приложения, задача — шагом к ней:
- заголовок цели отвечает на «что приложение будет уметь»; свойство поведения
  («сообщает о своём состоянии», «исход не зависит от порядка») — тоже
  возможность и переформулировки не требует;
- «Завершение» — списком, а не абзацем: задача ссылается на его строку, и это
  новая защита от «отрефакторить X» вместо прежнего «наблюдаемо снаружи».
  Заодно видно обратное: строка, к которой не относится ни одна задача, —
  незакрытая часть возможности;
- работа над инструментом и процессом на этот вопрос не отвечает и живёт в
  отдельной секции.

Цель обязательна не у всякой задачи. Прежнее «иначе она не попадёт ни в один
спринт» было угрозой, а не аргументом, и заставляло операционную работу
выдумывать себе направление. Граница по роду: feature без цели не бывает, fix,
chore и research живут без неё и входят в набор помимо цели спринта.

Тип [epic] упразднён: зонтиком стала цель, а слишком крупный шаг дробится под
ней. Ноль употреблений на 97 записей двух живых проектов.

Секции роадмапа — умеет / строим / направления / станок, четыре вместо двух;
имена приняты как временные и запаркованы (TODO 7). Имя секции достигнутого
знает скрипт — docs/.pm.json, ключ tasks.achieved_section. reopen цели снимает
строку достигнутого, круг проверен вживую.

Всё дописано в версию 3 канона: она ещё нигде не выкачена. DECISIONS 19,
YYY–ГГГ и следствия 78–81.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-04 17:43:55 +03:00
co-authored by Claude Opus 5
parent 5bf599a767
commit e847bfa0ea
13 changed files with 453 additions and 136 deletions
+119 -28
View File
@@ -12,7 +12,7 @@ av-dev, и подгоняется под него проект. Имена вн
docs/tasks/
items/ задачи и цели файлами, <slug>.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Шаг помечен закрытым — цель, скорее всего,"