канон версии 3: роадмап, род работы, границы задачи
Три изменения одной версией, потому что все три про одно — можно ли оценить задачу, не открывая код. PLAN.md → ROADMAP.md. Слово «план» значило в репозитории три разных вещи: оглавление целей, план реализации внутри задачи и PLAN.json разовой адаптации. Переименовано целиком — ключ конфига tasks.plan → tasks.roadmap, --index roadmap, --roadmap-sections, --roadmap. Старый ключ в docs/.pm.json не игнорируется молча: скрипт останавливается кодом 3 и называет переименование, иначе проект искал бы опечатку там, где на самом деле версия канона. Род работы — тег kind:feature|fix|chore|research, вторая ось поверх типа записи. В один префикс их не свести: идея бывает про функцию, эпик функцией и является. Дом — тег, потому что теги здесь единственный механизм разметки, а list --kind работает даром; цена принята — в строку индекса род не попадает. Словарь закрыт, иначе он разъедется на bug/bugfix/fix/defect. Отдельно легализован chore: у него «что станет наблюдаемо иначе» отвечается разработчику, а раньше такие задачи либо не заводились, либо придумывали себе пользовательскую пользу — и это второе хуже, оно проходит проверку. Раздел «Затрагивает» — границы, которых изменение касается: эндпоинт, таблица и миграция, формат на диске, публичный тип пакета. Без него задача оценивается по объёму текста, а не по объёму поверхности. Механизируется только наличие непустого раздела: полноту перечня машина не видит. Род и границы требуются к взятию в спринт, а не к заведению — тот же приём, что уже работает для критериев приёмки, и по той же причине. check о пропаже только напоминает: иначе два живых проекта покраснели бы на 98 задачах, заведённых до этого решения. Плюс правила языка задач: англицизм, у которого есть русское слово, заменяется; термин не из паспорта, архитектуры или конвенций вводится строкой или не употребляется; задача, которую не удаётся сказать просто, чаще всего не одна задача. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ av-dev, и подгоняется под него проект. Имена вн
|
||||
|
||||
docs/tasks/
|
||||
items/ задачи и цели файлами, <slug>.md
|
||||
PLAN.md оглавление целей: порядок (значим) и темы (без порядка)
|
||||
ROADMAP.md оглавление целей: порядок (значим) и темы (без порядка)
|
||||
BACKLOG.md что можно взять — только задачи, целей здесь нет
|
||||
SPRINT.md текущий спринт: цель, набор, дата, слаг
|
||||
REJECTED.md ушедшее БЕЗ реализации, с причиной и датой
|
||||
@@ -32,14 +32,21 @@ av-dev, и подгоняется под него проект. Имена вн
|
||||
направление; эпик временен — это задача, которая не мерджится целиком, её
|
||||
разбирают, и он исчезает.
|
||||
|
||||
**Род работы — вторая ось, и она отвечает на другой вопрос.** Тип записи говорит,
|
||||
что это за запись; род (`feature` | `fix` | `chore` | `research`, тегом
|
||||
`kind:<род>`) — какого рода работа. Одним значением на два вопроса не ответить:
|
||||
идея бывает *про* функцию, эпик *и есть* функция. Словарь закрыт — открытый
|
||||
разъедется на синонимах, и отбор по роду перестанет отвечать.
|
||||
|
||||
Использование:
|
||||
tasks.py init [--dir DIR] [--sections …] [--plan-sections …] [--items …]
|
||||
tasks.py init [--dir DIR] [--sections …] [--roadmap-sections …] [--items …]
|
||||
tasks.py check [--dir DIR] [--fix]
|
||||
tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag a,b]
|
||||
[--goal S] [--index backlog|sprint|plan|all] [--questions]
|
||||
[--goal S] [--kind K] [--index backlog|sprint|roadmap|all]
|
||||
[--questions]
|
||||
tasks.py add --slug S --title T [--type goal|idea|epic] [--section S]
|
||||
[--goal G] [--why H] [--reason R] [--tag a,b] [--dir DIR]
|
||||
tasks.py edit S [--title T] [--why H] [--type T] [--goal G]
|
||||
[--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]
|
||||
tasks.py move S --section S [--reason R] [--after S | --first] [--dir DIR]
|
||||
tasks.py close S (--reason R | --implemented) [--dir DIR]
|
||||
@@ -96,21 +103,22 @@ EXIT_INTERNAL = 4
|
||||
DEFAULTS = {
|
||||
"items": "items",
|
||||
"backlog": "BACKLOG.md",
|
||||
"plan": "PLAN.md",
|
||||
"roadmap": "ROADMAP.md",
|
||||
"sprint": "SPRINT.md",
|
||||
"rejected": "REJECTED.md",
|
||||
"sprint_section": "Набор",
|
||||
"criteria_heading": "Критерии приёмки",
|
||||
"surface_heading": "Затрагивает",
|
||||
"questions_heading": "Вопросы",
|
||||
"oracle_word": "оракул",
|
||||
}
|
||||
|
||||
# Какие ключи конфига — имена файлов и каталогов (их существование сверяется
|
||||
# с диском первым делом, иначе кривой ключ выглядит как пропавший файл).
|
||||
PATH_KEYS = ("items", "backlog", "plan", "sprint", "rejected")
|
||||
PATH_KEYS = ("items", "backlog", "roadmap", "sprint", "rejected")
|
||||
|
||||
DEFAULT_SECTIONS = "ядро,инфра"
|
||||
DEFAULT_PLAN_SECTIONS = "порядок,темы"
|
||||
DEFAULT_ROADMAP_SECTIONS = "порядок,темы"
|
||||
|
||||
# Мета — список под заголовком, поле на строку. Старая форма (все поля одной
|
||||
# строкой через `·`) читается по-прежнему: у проектов на диске лежат файлы в
|
||||
@@ -177,6 +185,13 @@ TAKEABLE = (PLAIN_TYPE,) # что вообще можно взять
|
||||
QUESTION_TAG = "question"
|
||||
GOAL_TAG = "goal:"
|
||||
SPRINT_TAG = "sprint:"
|
||||
# Род работы — вторая ось типа. Первая («тип записи»: goal/idea/epic/task)
|
||||
# отвечает «что это за запись», вторая — «какого рода работа». Смешивать их в
|
||||
# одном префиксе нельзя: идея *про* функцию, эпик *и есть* функция, и одно
|
||||
# значение на два вопроса не отвечает. Дом рода — тег, потому что теги здесь и
|
||||
# есть единственный механизм разметки, а `list --tag` уже умеет отбирать.
|
||||
KIND_TAG = "kind:"
|
||||
KINDS = ("feature", "fix", "chore", "research")
|
||||
DECOMPOSED_TAG = "decomposed" # цель разложена на задачи (см. «Статус цели»)
|
||||
STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check
|
||||
CRITERIA_MIN, CRITERIA_MAX = 2, 5 # сколько утверждений в критериях приёмки
|
||||
@@ -231,6 +246,20 @@ def bad_tags(raw: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def bad_kind(kind: str | None) -> str | None:
|
||||
"""Род работы — закрытый словарь: открытый разъедется на синонимах.
|
||||
|
||||
Пять человек заведут `bug`, `bugfix`, `fix`, `defect` и `починка`, и отбор
|
||||
по роду перестанет отвечать на свой единственный вопрос.
|
||||
"""
|
||||
if kind is None:
|
||||
return None
|
||||
if kind.strip().lower() not in KINDS:
|
||||
return (f"род работы «{kind}» не из словаря: {', '.join(KINDS)}."
|
||||
f" Не подходит ни один — это сигнал, что задача не одна")
|
||||
return None
|
||||
|
||||
|
||||
def split_tags(raw: str | None) -> list[str]:
|
||||
return [t.strip().lower() for t in (raw or "").split(",") if t.strip()]
|
||||
|
||||
@@ -315,7 +344,7 @@ class Layout:
|
||||
|
||||
@property
|
||||
def indexes(self) -> tuple[str, ...]:
|
||||
return ("backlog", "sprint", "plan")
|
||||
return ("backlog", "sprint", "roadmap")
|
||||
|
||||
|
||||
def load_config(root: Path) -> dict:
|
||||
@@ -356,6 +385,14 @@ def _read_json(path: Path) -> dict:
|
||||
|
||||
def _validate_config(data: dict, path: Path) -> dict:
|
||||
unknown = set(data) - set(DEFAULTS)
|
||||
# Ключ «plan» был домом оглавления целей до того, как файл стал ROADMAP.md.
|
||||
# Без этой ветки проект со старым конфигом получал бы «неизвестный ключ» и
|
||||
# искал опечатку там, где на самом деле переименование канона.
|
||||
if "plan" in unknown:
|
||||
raise Env(f"{path}: ключ «plan» переименован в «roadmap»,"
|
||||
f" а PLAN.md — в ROADMAP.md. Повысь проект скиллом"
|
||||
f" av-dev-pm:canon (upgrade), а не правь ключ в одиночку:"
|
||||
f" файл и ссылки на него переезжают вместе с ним")
|
||||
if unknown:
|
||||
raise Env(f"{path}: неизвестные ключи: {', '.join(sorted(unknown))}"
|
||||
f" (известны: {', '.join(sorted(DEFAULTS))})")
|
||||
@@ -391,7 +428,7 @@ def config_problems(lay: Layout) -> list[str]:
|
||||
out = []
|
||||
if not lay.items.is_dir():
|
||||
out.append(f"{where}: items = «{lay.cfg['items']}» → {lay.items} — каталога нет")
|
||||
for kind in ("backlog", "plan", "sprint", "rejected"):
|
||||
for kind in ("backlog", "roadmap", "sprint", "rejected"):
|
||||
p = lay.index(kind)
|
||||
if not p.is_file():
|
||||
out.append(f"{where}: {kind} = «{lay.cfg[kind]}» → {p} — файла нет")
|
||||
@@ -525,9 +562,9 @@ def parse_task(path: Path) -> dict:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
lines = text.splitlines()
|
||||
title = lines[0].removeprefix("#").strip() if lines and lines[0].startswith("#") else ""
|
||||
kind, bare = PLAIN_TYPE, title
|
||||
rtype, bare = PLAIN_TYPE, title
|
||||
if (m := TYPE_PREFIX.match(title)):
|
||||
kind, bare = m.group(1).strip().lower(), m.group(2).strip()
|
||||
rtype, bare = m.group(1).strip().lower(), m.group(2).strip()
|
||||
# Мета — блок под заголовком (task-format.md). Порядок полей свободный:
|
||||
# секция распознаётся, где бы она ни стояла.
|
||||
section, reason, why, tags, legacy = "", "", "", [], False
|
||||
@@ -543,8 +580,10 @@ def parse_task(path: Path) -> dict:
|
||||
elif key in ("теги", "tags"):
|
||||
tags = [t.strip().lower() for t in value.split(",") if t.strip()]
|
||||
goal = next((t[len(GOAL_TAG):] for t in tags if t.startswith(GOAL_TAG)), "")
|
||||
return {"title": title, "bare": bare, "type": kind, "section": section,
|
||||
"reason": reason, "why": why, "tags": tags, "goal": goal, "path": path,
|
||||
kind = next((t[len(KIND_TAG):] for t in tags if t.startswith(KIND_TAG)), "")
|
||||
return {"title": title, "bare": bare, "type": rtype, "section": section,
|
||||
"reason": reason, "why": why, "tags": tags, "goal": goal, "kind": kind,
|
||||
"path": path,
|
||||
"legacy_meta": legacy, "text": text, "body": body_sections(text)}
|
||||
|
||||
|
||||
@@ -557,7 +596,7 @@ def tasks_of(lay: Layout) -> dict[str, dict]:
|
||||
def home_index(task: dict) -> str:
|
||||
"""Индекс, которому задача принадлежит по типу. Спринт — исключение: туда
|
||||
задача попадает не по типу, а решением набора."""
|
||||
return "plan" if task["type"] == GOAL else "backlog"
|
||||
return "roadmap" if task["type"] == GOAL else "backlog"
|
||||
|
||||
|
||||
def touched_map(lay: Layout) -> dict[str, str]:
|
||||
@@ -624,6 +663,25 @@ def criteria_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]:
|
||||
return errors, notes
|
||||
|
||||
|
||||
def surface_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]:
|
||||
"""Отказы и замечания по разделу «Затрагивает». Общее для check и take.
|
||||
|
||||
Раздел называет **границы**, которых изменение касается: эндпоинт, команду,
|
||||
таблицу и миграцию, формат на диске, публичный тип пакета. Без него задача
|
||||
оценивается по объёму текста, а не по объёму поверхности, — и оценка
|
||||
систематически занижена ровно там, где текст короткий, а границ много.
|
||||
|
||||
Механизируется только наличие непустого раздела. Полнота перечня машине не
|
||||
видна: границу, которую забыли назвать, от отсутствующей она не отличает.
|
||||
"""
|
||||
name = task["path"].name
|
||||
if not task["body"].get(lay.cfg["surface_heading"].lower()):
|
||||
return ([f"{name}: нет раздела «{lay.cfg['surface_heading']}»"
|
||||
f" — оценивать будет не по чему: границы (эндпоинт, таблица и"
|
||||
f" миграция, формат на диске, публичный тип) не названы"], [])
|
||||
return [], []
|
||||
|
||||
|
||||
def questions_open(lay: Layout, task: dict) -> bool:
|
||||
"""Открытый вопрос — это **непустой раздел**, а не тег.
|
||||
|
||||
@@ -790,6 +848,21 @@ def check(lay: Layout, fix: bool = False) -> int:
|
||||
errors.append(f"{name}: тег {GOAL_TAG}{task['goal']} указывает на цель,"
|
||||
f" которой нет в {lay.cfg['items']}/")
|
||||
|
||||
# 3а. Род работы. Замечание, а не отказ: беклог, заведённый до появления
|
||||
# рода, законен, и переоформлять его «заодно» здесь не просят.
|
||||
# Обязательным род становится там, где по нему принимают решение, —
|
||||
# при взятии в спринт.
|
||||
if task["kind"] and task["kind"] not in KINDS:
|
||||
errors.append(f"{name}: род работы «{task['kind']}» не из словаря"
|
||||
f" ({', '.join(KINDS)}) — словарь закрыт, иначе отбор"
|
||||
f" по роду разъедется на синонимах")
|
||||
elif task["type"] == GOAL and task["kind"]:
|
||||
errors.append(f"{name}: у цели род работы «{task['kind']}» —"
|
||||
f" цель это направление, а не работа; род несут её задачи")
|
||||
elif task["type"] in TAKEABLE and not task["kind"]:
|
||||
notes.append(f"{name}: без рода работы — `tasks.py edit {name[:-3]}"
|
||||
f" --kind {'|'.join(KINDS)}`; в спринт без него не возьмут")
|
||||
|
||||
# 4. Спринт: задача с открытым вопросом в набор не берётся, и судит об
|
||||
# этом раздел, а не тег.
|
||||
if place == "sprint":
|
||||
@@ -806,9 +879,13 @@ def check(lay: Layout, fix: bool = False) -> int:
|
||||
if goal_of_sprint and task["goal"] and task["goal"] != goal_of_sprint:
|
||||
errors.append(f"{name}: цель задачи «{task['goal']}» не цель спринта"
|
||||
f" «{goal_of_sprint}» — набор служит одной цели")
|
||||
e, n = criteria_verdict(lay, task)
|
||||
errors += [f"{x} (в спринте)" for x in e]
|
||||
notes += n
|
||||
if not task["kind"]:
|
||||
errors.append(f"{name}: в спринте без рода работы"
|
||||
f" (`edit {name[:-3]} --kind …`)")
|
||||
for verdict in (criteria_verdict, surface_verdict):
|
||||
e, n = verdict(lay, task)
|
||||
errors += [f"{x} (в спринте)" for x in e]
|
||||
notes += n
|
||||
|
||||
# 5. Тег «question» производен от раздела: раздел — факт, тег — метка.
|
||||
has_q_section = questions_open(lay, task)
|
||||
@@ -956,6 +1033,8 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int:
|
||||
continue
|
||||
if a.goal and t["goal"] != a.goal.lower():
|
||||
continue
|
||||
if a.kind and t["kind"] != a.kind.lower():
|
||||
continue
|
||||
if a.questions and not questions_open(lay, t):
|
||||
continue
|
||||
rows.append(t)
|
||||
@@ -970,11 +1049,12 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int:
|
||||
|
||||
for t in rows:
|
||||
touched = f"{t.get('touched', ''):<11}" if a.stale else ""
|
||||
kind = "" if t["type"] == PLAIN_TYPE else f"[{t['type']}] "
|
||||
rtype = "" if t["type"] == PLAIN_TYPE else f"[{t['type']}] "
|
||||
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}{flag} "
|
||||
f"{t['path'].stem:<44} {kind}{t['bare']}{goal}")
|
||||
print(f"{touched}{t['place']:<8}{t['section']:<8}{kind}{flag} "
|
||||
f"{t['path'].stem:<44} {rtype}{t['bare']}{goal}")
|
||||
print(f"\nвсего: {len(rows)}")
|
||||
|
||||
# Пустой ответ обязан объясняться: молчаливый ноль читается как «таких
|
||||
@@ -1038,7 +1118,7 @@ def insert_entry(lines: list[str], section: str, entry: str,
|
||||
after: str | None = None, first: bool = False) -> None:
|
||||
"""Вставляет строку в секцию: по умолчанию в конец, --after <слаг> — следом
|
||||
за указанной строкой, --first — первой. Порядок нужен только упорядоченной
|
||||
части плана; в беклоге он значения не имеет."""
|
||||
части роадмапа; в беклоге он значения не имеет."""
|
||||
hi, _ = find_section(lines, section)
|
||||
if hi is None:
|
||||
raise Usage(f"секции «{section}» в индексе нет")
|
||||
@@ -1125,6 +1205,11 @@ def body_template(kind: str, lay: Layout) -> str:
|
||||
return ("<!-- что за идея, откуда взялась, чем может быть полезна;"
|
||||
" критериев у идеи нет — потому она и идея -->\n")
|
||||
return ("<!-- задача в одной фразе: что станет наблюдаемо иначе -->\n\n"
|
||||
f"## {lay.cfg['surface_heading']}\n\n"
|
||||
"<!-- границы, которых изменение касается: эндпоинт или команда,"
|
||||
" таблица и миграция, формат на диске, публичный тип пакета,"
|
||||
" внешний сервис. Названы границы, а не то, как они изменятся:"
|
||||
" план реализации живёт в предложении, а не здесь -->\n\n"
|
||||
f"## {lay.cfg['criteria_heading']}\n\n"
|
||||
f"<!-- {CRITERIA_MIN}–{CRITERIA_MAX} проверяемых утверждений списком,"
|
||||
f" у каждого назван {lay.cfg['oracle_word']} -->\n\n"
|
||||
@@ -1135,17 +1220,18 @@ def body_template(kind: str, lay: Layout) -> str:
|
||||
|
||||
def cmd_add(lay: Layout, a: argparse.Namespace) -> int:
|
||||
for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_why(a.why),
|
||||
bad_tags(a.tag), bad_reason(a.reason), bad_slug(a.goal) if a.goal else None):
|
||||
bad_tags(a.tag), bad_reason(a.reason), bad_kind(a.kind),
|
||||
bad_slug(a.goal) if a.goal else None):
|
||||
if err:
|
||||
raise Usage(err)
|
||||
if not a.title.strip():
|
||||
raise Usage("пустой заголовок")
|
||||
kind = (a.type or "").strip().lower()
|
||||
rtype = (a.type or "").strip().lower()
|
||||
path = lay.items / f"{a.slug}.md"
|
||||
if path.exists():
|
||||
raise Usage(f"{path.name} уже существует — дедуп: допиши в него, а не заводи новый")
|
||||
|
||||
target = "plan" if kind == GOAL else "backlog"
|
||||
target = "roadmap" if rtype == GOAL else "backlog"
|
||||
lines = read_lines(lay.index(target))
|
||||
if not lines:
|
||||
raise Usage(f"нет индекса {lay.name(target)} — прогони tasks.py init")
|
||||
@@ -1162,23 +1248,28 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int:
|
||||
tags = [t for t in tags if not t.startswith(GOAL_TAG)] + [f"{GOAL_TAG}{a.goal}"]
|
||||
if not (lay.items / f"{a.goal}.md").exists():
|
||||
print(f" внимание: цели {a.goal}.md нет — заведи её (--type goal) или поправь тег")
|
||||
if kind != GOAL and not any(t.startswith(GOAL_TAG) for t in tags):
|
||||
if a.kind:
|
||||
tags = [t for t in tags if not t.startswith(KIND_TAG)] + [f"{KIND_TAG}{a.kind.lower()}"]
|
||||
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 <слаг>`")
|
||||
# Урожай спринта метится сам: тег, который никто не ставит, не отбирает
|
||||
# первую порцию переоценки, а именно на ней держится правило «сперва урожай».
|
||||
sslug = sprint_slug(lay)
|
||||
if kind != GOAL and sslug and not any(t.startswith(SPRINT_TAG) for t in tags):
|
||||
if rtype != GOAL and sslug and not any(t.startswith(SPRINT_TAG) for t in tags):
|
||||
tags.append(f"{SPRINT_TAG}{sslug}")
|
||||
if QUESTION_TAG in tags:
|
||||
print(f" тег «{QUESTION_TAG}»: не забудь раздел «{lay.cfg['questions_heading']}» в теле")
|
||||
|
||||
title_full = f"[{kind}] {a.title}" if kind else a.title
|
||||
title_full = f"[{rtype}] {a.title}" if rtype else a.title
|
||||
meta = build_meta(section, a.reason or "", a.why or "", tags)
|
||||
insert_entry(lines, section, entry_line(lay, title_full, a.slug, a.why or ""))
|
||||
|
||||
plan = Plan()
|
||||
plan.file(path, f"# {title_full}\n\n{meta}\n\n{body_template(kind, lay)}")
|
||||
plan.file(path, f"# {title_full}\n\n{meta}\n\n{body_template(rtype, lay)}")
|
||||
plan.index(lay, target, lines)
|
||||
plan.commit()
|
||||
|
||||
@@ -1210,11 +1301,14 @@ def warn_rejected(lay: Layout, slug: str, title: str) -> None:
|
||||
|
||||
def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
|
||||
for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_why(a.why),
|
||||
bad_tags(a.add_tag), bad_tags(a.rm_tag), bad_slug(a.goal) if a.goal else None):
|
||||
bad_tags(a.add_tag), bad_tags(a.rm_tag), bad_kind(a.kind),
|
||||
bad_slug(a.goal) if a.goal else None):
|
||||
if err:
|
||||
raise Usage(err)
|
||||
if all(v is None for v in (a.title, a.why, a.type, a.goal, a.add_tag, a.rm_tag, a.section)):
|
||||
raise Usage("нечего менять: дай --title, --why, --type, --goal, --add-tag или --rm-tag")
|
||||
if all(v is None for v in (a.title, a.why, a.type, a.goal, a.kind,
|
||||
a.add_tag, a.rm_tag, a.section)):
|
||||
raise Usage("нечего менять: дай --title, --why, --type, --goal, --kind,"
|
||||
" --add-tag или --rm-tag")
|
||||
path = lay.items / f"{a.slug}.md"
|
||||
if not path.exists():
|
||||
raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/")
|
||||
@@ -1256,6 +1350,8 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
|
||||
tags = [t for t in tags if not t.startswith(GOAL_TAG)] + [f"{GOAL_TAG}{a.goal}"]
|
||||
if not (lay.items / f"{a.goal}.md").exists():
|
||||
print(f" внимание: цели {a.goal}.md нет — заведи её или поправь тег")
|
||||
if a.kind is not None:
|
||||
tags = [t for t in tags if not t.startswith(KIND_TAG)] + [f"{KIND_TAG}{a.kind.lower()}"]
|
||||
|
||||
why = task["why"] if a.why is None else a.why
|
||||
section = task["section"]
|
||||
@@ -1303,7 +1399,8 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
|
||||
plan.commit()
|
||||
|
||||
changed = [n for n, v in (("заголовок", a.title), ("зачем", a.why), ("тип", a.type),
|
||||
("цель", a.goal), ("теги", a.add_tag or a.rm_tag))
|
||||
("цель", a.goal), ("род работы", a.kind),
|
||||
("теги", a.add_tag or a.rm_tag))
|
||||
if v is not None]
|
||||
print(f"{a.slug}: обновлено ({', '.join(changed)})")
|
||||
if len(places) > 1:
|
||||
@@ -1592,10 +1689,16 @@ def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int:
|
||||
raise Usage(f"{slug}: тег «{QUESTION_TAG}» стоит, а раздела"
|
||||
f" «{lay.cfg['questions_heading']}» нет — либо вопрос записан не туда,"
|
||||
f" либо тег пора снять: `edit {slug} --rm-tag {QUESTION_TAG}`")
|
||||
errs, notes = criteria_verdict(lay, t)
|
||||
if errs:
|
||||
raise Usage("; ".join(errs))
|
||||
warn += notes
|
||||
if not t["kind"]:
|
||||
raise Usage(f"{slug}: род работы не назван —"
|
||||
f" `edit {slug} --kind {'|'.join(KINDS)}`."
|
||||
f" По нему видно, что в наборе одни починки и ни одной"
|
||||
f" функции, а это разговор про цель, а не про набор")
|
||||
for verdict in (criteria_verdict, surface_verdict):
|
||||
errs, notes = verdict(lay, t)
|
||||
if errs:
|
||||
raise Usage("; ".join(errs))
|
||||
warn += notes
|
||||
ei = find_entry_index(backlog_lines, slug)
|
||||
if ei is None:
|
||||
raise Usage(f"{slug}: строки в {lay.name('backlog')} нет"
|
||||
@@ -1699,7 +1802,7 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
|
||||
меты файла;
|
||||
- «зачем», оставшееся только в индексе, — переносится в файл (миграция со
|
||||
старого формата: другого экземпляра нет, неоднозначности тоже);
|
||||
- строка в чужом индексе (цель в беклоге, задача в плане) — переносится в
|
||||
- строка в чужом индексе (цель в беклоге, задача в роадмапе) — переносится в
|
||||
домашний. Задача лежит ровно в одном индексе и не в том, выбирать не из
|
||||
чего: истина в типе, а тип в файле;
|
||||
- цель, у которой есть задачи, помечается `decomposed`.
|
||||
@@ -1853,7 +1956,7 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
|
||||
|
||||
# --- init ---
|
||||
|
||||
def init_files(lay: Layout, sections: list[str], plan_sections: list[str],
|
||||
def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str],
|
||||
cfg: dict) -> dict[Path, str]:
|
||||
out: dict[Path, str] = {}
|
||||
if cfg:
|
||||
@@ -1875,20 +1978,20 @@ def init_files(lay: Layout, sections: list[str], plan_sections: list[str],
|
||||
"# Беклог\n\n"
|
||||
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
|
||||
"+ строка здесь. Целей тут нет — они в "
|
||||
f"[{lay.name('plan')}]({lay.name('plan')}): беклог — то, что берут,\n"
|
||||
"план — то, подо что берут. Порядка внутри секции нет: «что делать\n"
|
||||
f"[{lay.name('roadmap')}]({lay.name('roadmap')}): беклог — то, что берут,\n"
|
||||
"роадмап — то, подо что берут. Порядка внутри секции нет: «что делать\n"
|
||||
f"дальше» отвечает набор спринта. Ведётся скиллом `tasks`.\n\n"
|
||||
"Секции «блокеры» здесь нет и не заводится: блокер — это состояние\n"
|
||||
"(спринт не может продолжаться ни одной задачей), оно живёт до ответа\n"
|
||||
"человека, а его следы — вопросами в файлах задач.\n\n"
|
||||
+ "".join(f"## {s}\n\n" for s in sections))
|
||||
out[lay.index("plan")] = (
|
||||
"# План\n\n"
|
||||
out[lay.index("roadmap")] = (
|
||||
"# Роадмап\n\n"
|
||||
f"Оглавление целей. Цель — файл `[goal]` в `{lay.cfg['items']}/`; её задачи\n"
|
||||
"здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.\n"
|
||||
f"В первой секции («{plan_sections[0]}») очередь значима и обосновывается\n"
|
||||
f"В первой секции («{roadmap_sections[0]}») очередь значима и обосновывается\n"
|
||||
"прозой; в остальных порядка нет — это тематические цели.\n\n"
|
||||
+ "".join(f"## {s}\n\n" for s in plan_sections))
|
||||
+ "".join(f"## {s}\n\n" for s in roadmap_sections))
|
||||
out[lay.index("sprint")] = empty_sprint(lay)
|
||||
out[lay.index("rejected")] = (
|
||||
"# Ушедшее без реализации\n\n"
|
||||
@@ -1911,14 +2014,14 @@ def uniq_sections(raw: str) -> list[str]:
|
||||
def cmd_init(root: Path, a: argparse.Namespace) -> int:
|
||||
if not dir_within_cwd(root):
|
||||
raise Usage(f"--dir вне рабочего каталога: {root}")
|
||||
cfg = {k: v for k, v in (("items", a.items), ("backlog", a.backlog), ("plan", a.plan),
|
||||
cfg = {k: v for k, v in (("items", a.items), ("backlog", a.backlog), ("roadmap", a.roadmap),
|
||||
("sprint", a.sprint), ("rejected", a.rejected)) if v}
|
||||
lay = Layout(root, cfg)
|
||||
if lay.index("backlog").exists():
|
||||
raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён")
|
||||
|
||||
sections, plan_sections = uniq_sections(a.sections), uniq_sections(a.plan_sections)
|
||||
if not sections or not plan_sections:
|
||||
sections, roadmap_sections = uniq_sections(a.sections), uniq_sections(a.roadmap_sections)
|
||||
if not sections or not roadmap_sections:
|
||||
raise Usage("пустой список секций")
|
||||
blockers = [s for s in sections if s.lower() in BLOCKER_SECTIONS]
|
||||
if blockers:
|
||||
@@ -1930,11 +2033,12 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
|
||||
|
||||
lay.items.mkdir(parents=True, exist_ok=True)
|
||||
plan = Plan()
|
||||
for path, text in init_files(lay, sections, plan_sections, cfg).items():
|
||||
for path, text in init_files(lay, sections, roadmap_sections, cfg).items():
|
||||
plan.file(path, text)
|
||||
plan.commit()
|
||||
print(f"каталог задач заведён: {root}")
|
||||
print(f" секции беклога: {', '.join(sections)}; части плана: {', '.join(plan_sections)}")
|
||||
print(f" секции беклога: {', '.join(sections)};"
|
||||
f" части роадмапа: {', '.join(roadmap_sections)}")
|
||||
if cfg:
|
||||
pm = (root / PM_CONFIG_REL).resolve()
|
||||
print(f" имена частей записаны в {pm if pm.is_file() else root / CONFIG_NAME}")
|
||||
@@ -2091,7 +2195,7 @@ def cmd_adopt_scan(a: argparse.Namespace) -> int:
|
||||
rejected += sc.get("rejected", [])
|
||||
unclassified += sc.get("unclassified", [])
|
||||
goals += [{"slug": "", "title": g["title"],
|
||||
"section": (a.plan_sections.split(",")[0].strip() or "порядок"),
|
||||
"section": (a.roadmap_sections.split(",")[0].strip() or "порядок"),
|
||||
"from": g["from"], "step": g.get("step"), "done": g.get("done"),
|
||||
"body": f"Выведена из шага «{g['title']}» ({g['from']})."
|
||||
+ ("\n\nШаг помечен закрытым — цель, скорее всего,"
|
||||
@@ -2125,7 +2229,7 @@ def cmd_adopt_scan(a: argparse.Namespace) -> int:
|
||||
"sources": [str(s) for s in sources],
|
||||
"path_map": path_map,
|
||||
"sections_backlog": uniq_sections(a.sections),
|
||||
"sections_plan": uniq_sections(a.plan_sections),
|
||||
"sections_roadmap": uniq_sections(a.roadmap_sections),
|
||||
"section_map": {},
|
||||
"goals": goals,
|
||||
"items": items,
|
||||
@@ -2242,9 +2346,9 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
||||
raise Usage(f"target вне рабочего каталога: {root}")
|
||||
lay = Layout(root, {})
|
||||
sections = pl.get("sections_backlog") or uniq_sections(DEFAULT_SECTIONS)
|
||||
plan_sections = pl.get("sections_plan") or uniq_sections(DEFAULT_PLAN_SECTIONS)
|
||||
roadmap_sections = pl.get("sections_roadmap") or uniq_sections(DEFAULT_ROADMAP_SECTIONS)
|
||||
known_sections = {s.lower() for s in sections}
|
||||
known_plan = {s.lower() for s in plan_sections}
|
||||
known_roadmap = {s.lower() for s in roadmap_sections}
|
||||
|
||||
# --- проверки: все до первой записи ---
|
||||
problems: list[str] = []
|
||||
@@ -2261,9 +2365,9 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
||||
if g["slug"] in slugs:
|
||||
problems.append(f"слаг «{g['slug']}» встречается дважды")
|
||||
slugs.add(g["slug"])
|
||||
if g.get("section", "").lower() not in known_plan:
|
||||
if g.get("section", "").lower() not in known_roadmap:
|
||||
problems.append(f"цель {g['slug']}: секция «{g.get('section', '')}»"
|
||||
f" не из плана ({', '.join(plan_sections)})")
|
||||
f" не из роадмапа ({', '.join(roadmap_sections)})")
|
||||
goal_slugs = {g["slug"] for g in pl.get("goals", []) if g.get("slug")}
|
||||
for it in pl.get("items", []):
|
||||
slug = it.get("slug") or it.get("old_slug")
|
||||
@@ -2290,10 +2394,10 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
||||
|
||||
# --- план записи ---
|
||||
wr = Plan()
|
||||
for path, text in init_files(lay, sections, plan_sections, {}).items():
|
||||
for path, text in init_files(lay, sections, roadmap_sections, {}).items():
|
||||
wr.file(path, text)
|
||||
backlog_lines = init_files(lay, sections, plan_sections, {})[lay.index("backlog")].splitlines()
|
||||
plan_lines = init_files(lay, sections, plan_sections, {})[lay.index("plan")].splitlines()
|
||||
backlog_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("backlog")].splitlines()
|
||||
roadmap_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("roadmap")].splitlines()
|
||||
|
||||
renames: dict[str, str] = {}
|
||||
for g in pl.get("goals", []):
|
||||
@@ -2304,7 +2408,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
||||
wr.file(lay.items / f"{g['slug']}.md",
|
||||
f"# {title}\n\n{meta}\n\n{body}\n\n## Завершение\n\n"
|
||||
f"<!-- по чему видно, что цель достигнута -->\n")
|
||||
insert_entry(plan_lines, g["section"].lower(),
|
||||
insert_entry(roadmap_lines, g["section"].lower(),
|
||||
entry_line(lay, title, g["slug"], g.get("why", "")))
|
||||
|
||||
for it in pl.get("items", []):
|
||||
@@ -2339,7 +2443,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
||||
entry_line(lay, title, slug, it.get("why", "")))
|
||||
|
||||
if pl.get("rejected"):
|
||||
head = init_files(lay, sections, plan_sections, {})[lay.index("rejected")]
|
||||
head = init_files(lay, sections, roadmap_sections, {})[lay.index("rejected")]
|
||||
body = []
|
||||
for line in pl["rejected"]:
|
||||
line = re.sub(r"Был приоритет:", "Была секция:", line)
|
||||
@@ -2349,7 +2453,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
||||
wr.file(lay.index("rejected"), head + "\n".join(body) + "\n")
|
||||
|
||||
wr.file(lay.index("backlog"), "\n".join(backlog_lines))
|
||||
wr.file(lay.index("plan"), "\n".join(plan_lines))
|
||||
wr.file(lay.index("roadmap"), "\n".join(roadmap_lines))
|
||||
|
||||
if a.dry_run:
|
||||
print(f"пробный прогон: записалось бы файлов {len(wr.writes)},"
|
||||
@@ -2422,7 +2526,8 @@ def main() -> int:
|
||||
p.add_argument("--tag", help="тег или список через запятую (нужны ВСЕ):"
|
||||
" goal:<слаг>, question, sprint:<слаг>")
|
||||
p.add_argument("--goal", help="задачи одной цели (перечень выводится, а не хранится)")
|
||||
p.add_argument("--index", choices=("backlog", "sprint", "plan", "all"))
|
||||
p.add_argument("--kind", choices=KINDS, help="род работы → тег kind:<род>")
|
||||
p.add_argument("--index", choices=("backlog", "sprint", "roadmap", "all"))
|
||||
p.add_argument("--questions", action="store_true", help="только с открытым вопросом")
|
||||
|
||||
p = sub.add_parser("add", help="завести задачу, идею или цель")
|
||||
@@ -2432,27 +2537,29 @@ def main() -> int:
|
||||
p.add_argument("--type", choices=TYPES)
|
||||
p.add_argument("--section")
|
||||
p.add_argument("--goal", help="слаг цели → тег goal:<слаг>")
|
||||
p.add_argument("--kind", choices=KINDS, help="род работы → тег kind:<род>")
|
||||
p.add_argument("--why")
|
||||
p.add_argument("--reason")
|
||||
p.add_argument("--tag")
|
||||
|
||||
p = sub.add_parser("edit", help="сменить заголовок/«зачем»/тип/цель/теги")
|
||||
p = sub.add_parser("edit", help="сменить заголовок/«зачем»/тип/род/цель/теги")
|
||||
p.add_argument("slug")
|
||||
p.add_argument("--title")
|
||||
p.add_argument("--why")
|
||||
p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE))
|
||||
p.add_argument("--goal", help="заменить тег goal:<слаг>")
|
||||
p.add_argument("--kind", choices=KINDS, help="заменить тег kind:<род>")
|
||||
p.add_argument("--add-tag", dest="add_tag")
|
||||
p.add_argument("--rm-tag", dest="rm_tag")
|
||||
p.add_argument("--section", help="только вместе со сменой типа, меняющей индекс")
|
||||
p.add_argument("--dir")
|
||||
|
||||
p = sub.add_parser("move", help="перенести в другую секцию беклога или часть плана")
|
||||
p = sub.add_parser("move", help="перенести в другую секцию беклога или часть роадмапа")
|
||||
p.add_argument("slug")
|
||||
p.add_argument("--section", required=True)
|
||||
p.add_argument("--reason")
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument("--after", help="встать следом за этим слагом (упорядоченная часть плана)")
|
||||
g.add_argument("--after", help="встать следом за этим слагом (упорядоченная часть роадмапа)")
|
||||
g.add_argument("--first", action="store_true")
|
||||
p.add_argument("--dir")
|
||||
|
||||
@@ -2490,10 +2597,10 @@ def main() -> int:
|
||||
p = sub.add_parser("init", help="завести каталог задач в новом проекте")
|
||||
p.add_argument("--dir")
|
||||
p.add_argument("--sections", default=DEFAULT_SECTIONS)
|
||||
p.add_argument("--plan-sections", dest="plan_sections", default=DEFAULT_PLAN_SECTIONS)
|
||||
p.add_argument("--roadmap-sections", dest="roadmap_sections", default=DEFAULT_ROADMAP_SECTIONS)
|
||||
p.add_argument("--items")
|
||||
p.add_argument("--backlog")
|
||||
p.add_argument("--plan")
|
||||
p.add_argument("--roadmap")
|
||||
p.add_argument("--sprint")
|
||||
p.add_argument("--rejected")
|
||||
|
||||
@@ -2504,7 +2611,7 @@ def main() -> int:
|
||||
s.add_argument("--target", default="docs/tasks")
|
||||
s.add_argument("--out", default="tasks-adopt-plan.json")
|
||||
s.add_argument("--sections", default=DEFAULT_SECTIONS)
|
||||
s.add_argument("--plan-sections", dest="plan_sections", default=DEFAULT_PLAN_SECTIONS)
|
||||
s.add_argument("--roadmap-sections", dest="roadmap_sections", default=DEFAULT_ROADMAP_SECTIONS)
|
||||
s = asub.add_parser("apply", help="записать каталог по подтверждённой карте")
|
||||
s.add_argument("--plan", required=True)
|
||||
s.add_argument("--refs", nargs="*", help="файлы и каталоги, где чинить ссылки на слаги")
|
||||
|
||||
Reference in New Issue
Block a user