Соглашение об именах: длинное имя плагина с префиксом av-dev- (уникально в маркетплейсе), короткие имена скилов внутри. Вызов — /av-dev-backlog:backlog, единообразно для будущих плагинов. Путь к backlog.py в SKILL.md обновлён под новую раскладку ($CLAUDE_PLUGIN_ROOT/skills/backlog/scripts/backlog.py). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
707 lines
34 KiB
Python
Executable File
707 lines
34 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Детерминированный инструмент беклога: файлы задач против индекса README.
|
||
|
||
Согласованность беклога — механизируемая вещь, и держать её вниманием агента
|
||
дорого и ненадёжно. Скрипт не только проверяет, но и **пишет**: создание,
|
||
переименование, перенос между приоритетами и закрытие правят файл и индекс
|
||
заодно, так что рассогласовать их вручную нельзя. Всё, что здесь механизировано,
|
||
не должно попадать ни в промпт, ни в чек-лист человека.
|
||
|
||
Источник истины — файл задачи. Индекс производен от файлов: расходятся —
|
||
неправ индекс.
|
||
|
||
Тип задачи — ключевое слово (idea | epic | task); по-английски, как и прочие
|
||
токены команд. Обычная задача (task) префикса не несёт, idea/epic кодируются
|
||
префиксом `[idea]`/`[epic]` в заголовке. Текст самой задачи — русский.
|
||
|
||
Использование:
|
||
backlog.py check [--dir DIR] [--fix] согласованность (+ здоровье беклога);
|
||
--fix чинит безопасный дрейф
|
||
backlog.py list [--dir DIR] [фильтры] список задач
|
||
--stale от самой залежавшейся (дата последней правки из git)
|
||
--priority СЛОВО / --type idea|epic / --tag СЛОВО фильтры
|
||
backlog.py add --slug S --title T --priority P [--type idea|epic]
|
||
[--hook H] [--reason R] [--tag a,b] [--dir DIR]
|
||
создать задачу: файл + строка индекса
|
||
backlog.py edit S [--title T] [--hook H] [--type idea|epic|task] [--dir DIR]
|
||
сменить заголовок/хук/тип (файл + индекс)
|
||
backlog.py move S --priority P [--reason R] [--dir DIR]
|
||
перенести в другую секцию приоритета
|
||
backlog.py close S (--reason R | --implemented) [--dir DIR]
|
||
закрыть: --reason → кладбище + удаление,
|
||
--implemented → просто удаление (есть коммит)
|
||
backlog.py init [--dir DIR] [--sections "высокий,средний,низкий"]
|
||
завести пустой беклог в новом проекте
|
||
|
||
Тело задачи (контекст, шаги, ссылки) остаётся агенту — add кладёт лишь заголовок,
|
||
мета-строку и плейсхолдер; агент дописывает тело редактором.
|
||
|
||
Границы безопасности: слаг — только латиница kebab-case (traversal невозможен),
|
||
--dir обязан быть внутри рабочего каталога, в заголовок/хук/причину не пролезет
|
||
перевод строки, `·` в причине запрещён (это разделитель мета-полей).
|
||
|
||
Язык не зашит инструментально: приоритеты сопоставляются с заголовками секций
|
||
индекса как есть. Текст задач — русский.
|
||
"""
|
||
|
||
import argparse
|
||
import datetime
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
INDEX = "README.md"
|
||
CLOSED = "CLOSED.md"
|
||
SERVICE = {INDEX, CLOSED}
|
||
|
||
META_FIELD = re.compile(r"^\*\*(.+?):\*\*\s*(.*)$")
|
||
INDEX_ENTRY = re.compile(r"^- \[(.+?)\]\((.+?\.md)\)\s*(?:—\s*(.*))?$")
|
||
SECTION = re.compile(r"^##\s+(.+?)\s*$")
|
||
TYPE_PREFIX = re.compile(r"^\[(.+?)\]\s*(.*)$")
|
||
SLUG_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
||
SLUG = re.compile(SLUG_RE.pattern + r"\.md")
|
||
# Строка кладбища: - ГГГГ-ММ-ДД `slug` — текст
|
||
CLOSED_ENTRY = re.compile(r"^- \d{4}-\d{2}-\d{2} `[a-z0-9-]+` — .+")
|
||
|
||
TYPES = ("idea", "epic") # непустые типы-ключевые слова, префикс [..] в H1
|
||
PLAIN_TYPE = "task" # обычная задача — без префикса
|
||
STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check
|
||
|
||
|
||
# --- Валидация недоверенного ввода (аргументы могут прийти из текста задачи) ---
|
||
|
||
def bad_line(value: str, field: str) -> str | None:
|
||
"""Однострочность: перевод строки/управляющий символ ломает индекс и файл."""
|
||
if value is not None and (any(c in value for c in "\n\r") or any(ord(c) < 32 for c in value)):
|
||
return f"{field}: перевод строки или управляющий символ запрещён"
|
||
return None
|
||
|
||
|
||
def bad_slug(slug: str) -> str | None:
|
||
if not SLUG_RE.fullmatch(slug):
|
||
return f"слаг «{slug}» — только латиница kebab-case (без ../, точек, слэшей)"
|
||
return None
|
||
|
||
|
||
def bad_reason(reason: str | None) -> str | None:
|
||
if reason is None:
|
||
return None
|
||
if (e := bad_line(reason, "причина")):
|
||
return e
|
||
if "·" in reason:
|
||
return "причина: символ · зарезервирован под разделитель мета-полей"
|
||
return None
|
||
|
||
|
||
def dir_within_cwd(root: Path) -> bool:
|
||
try:
|
||
root.resolve().relative_to(Path.cwd().resolve())
|
||
return True
|
||
except ValueError:
|
||
return False
|
||
|
||
|
||
# --- Атомарная запись: падение посреди write не оставит усечённый индекс ---
|
||
|
||
def write_atomic(path: Path, text: str) -> None:
|
||
tmp = path.with_name(path.name + ".tmp")
|
||
tmp.write_text(text, encoding="utf-8")
|
||
os.replace(tmp, path)
|
||
|
||
|
||
def resolve_dir(explicit: str | None) -> Path:
|
||
"""Каталог беклога для команд, кроме init. Явный --dir обязан быть внутри cwd."""
|
||
if explicit:
|
||
root = Path(explicit)
|
||
if not dir_within_cwd(root):
|
||
sys.exit(f"--dir вне рабочего каталога: {explicit}")
|
||
if not (root / INDEX).is_file():
|
||
sys.exit(f"беклога нет в «{explicit}» (нет {INDEX}); новый проект — backlog.py init")
|
||
return root
|
||
for candidate in ("docs/backlog", "backlog", "doc/backlog", "docs/tasks"):
|
||
if (Path(candidate) / INDEX).is_file():
|
||
return Path(candidate)
|
||
sys.exit("каталог беклога не найден, укажи --dir"
|
||
" (искал: docs/backlog, backlog, doc/backlog, docs/tasks)")
|
||
|
||
|
||
def parse_index(root: Path) -> tuple[dict[str, dict], list[str]]:
|
||
"""Строки индекса по имени файла + порядок секций (он же порядок приоритетов).
|
||
|
||
Дубли имени файла тут схлопываются (побеждает последний) — их отдельно ловит
|
||
index_lint, поэтому опираться на этот dict как на полноту нельзя.
|
||
"""
|
||
entries: dict[str, dict] = {}
|
||
sections: list[str] = []
|
||
section = None
|
||
for num, line in enumerate((root / INDEX).read_text(encoding="utf-8").splitlines(), 1):
|
||
m = SECTION.match(line)
|
||
if m:
|
||
section = m.group(1)
|
||
sections.append(section)
|
||
continue
|
||
m = INDEX_ENTRY.match(line)
|
||
if m:
|
||
title, target, hook = m.group(1), m.group(2), (m.group(3) or "").strip()
|
||
entries[target] = {"title": title, "section": section, "hook": hook, "line": num}
|
||
return entries, sections
|
||
|
||
|
||
def index_lint(root: Path) -> list[str]:
|
||
"""Структурные дефекты индекса, которые схлопнутый dict parse_index не видит:
|
||
битые строки-пункты, дубли на один файл, задачи до первой секции приоритета."""
|
||
errors: list[str] = []
|
||
section = None
|
||
seen: dict[str, int] = {}
|
||
for num, line in enumerate((root / INDEX).read_text(encoding="utf-8").splitlines(), 1):
|
||
if SECTION.match(line):
|
||
section = SECTION.match(line).group(1)
|
||
continue
|
||
if not line.startswith("- ["):
|
||
continue
|
||
m = INDEX_ENTRY.match(line)
|
||
if not m:
|
||
errors.append(f"{INDEX}:{num}: строка-пункт не по формату"
|
||
f" «- [Заголовок](slug.md) — хук»")
|
||
continue
|
||
target = m.group(2)
|
||
if section is None:
|
||
errors.append(f"{INDEX}:{num}: {target} стоит до первой секции приоритета")
|
||
if target in seen:
|
||
errors.append(f"{INDEX}:{num}: дубль строки для {target}"
|
||
f" (первая — строка {seen[target]})")
|
||
else:
|
||
seen[target] = num
|
||
return errors
|
||
|
||
|
||
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
|
||
m = TYPE_PREFIX.match(title)
|
||
if m:
|
||
kind, bare = m.group(1).strip().lower(), m.group(2).strip()
|
||
# Мета-строка — первая непустая строка после заголовка (task-format.md).
|
||
# Поля разделены `·`, порядок свободный: приоритет распознаётся, где бы он ни
|
||
# стоял, а не только первым. Причина не должна содержать `·` — это разделитель.
|
||
meta = next((ln.strip() for ln in lines[1:] if ln.strip()), "")
|
||
priority, reason, tags = "", "", []
|
||
if META_FIELD.match(meta):
|
||
for chunk in meta.split("·"):
|
||
f = META_FIELD.match(chunk.strip())
|
||
if not f:
|
||
continue
|
||
key, value = f.group(1).strip().lower(), f.group(2).strip()
|
||
if key in ("приоритет", "priority"):
|
||
priority, _, reason = (p.strip() for p in value.partition("—"))
|
||
priority = priority.rstrip(".,").lower()
|
||
elif key in ("теги", "tags"):
|
||
tags = [t.strip().lower() for t in value.split(",") if t.strip()]
|
||
return {"title": title, "bare": bare, "type": kind, "priority": priority,
|
||
"reason": reason, "tags": tags, "path": path}
|
||
|
||
|
||
def tasks_of(root: Path) -> dict[str, dict]:
|
||
return {p.name: parse_task(p) for p in sorted(root.glob("*.md")) if p.name not in SERVICE}
|
||
|
||
|
||
def touched_map(root: Path) -> dict[str, str]:
|
||
"""Дата последнего коммита для каждого файла беклога — одним вызовом git.
|
||
Ключ — имя файла (в каталоге беклога имена уникальны). Нет git / нет
|
||
истории → пустая карта, вызывающий подставит «—»."""
|
||
try:
|
||
out = subprocess.run(["git", "log", "--format=%as", "--name-only", "--", str(root)],
|
||
capture_output=True, text=True).stdout
|
||
except FileNotFoundError:
|
||
return {}
|
||
dates: dict[str, str] = {}
|
||
cur = None
|
||
for line in out.splitlines():
|
||
if not line.strip():
|
||
continue
|
||
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", line):
|
||
cur = line # лог новейшие сверху → первая дата и есть последняя правка
|
||
elif cur:
|
||
dates.setdefault(os.path.basename(line), cur)
|
||
return dates
|
||
|
||
|
||
def check(root: Path, fix: bool = False) -> int:
|
||
if fix:
|
||
for line in apply_fixes(root):
|
||
print(f"ПОЧИНЕНО {line}")
|
||
print()
|
||
|
||
entries, sections = parse_index(root)
|
||
tasks = tasks_of(root)
|
||
known = {s.lower() for s in sections}
|
||
errors: list[str] = []
|
||
notes: list[str] = []
|
||
|
||
for name, task in tasks.items():
|
||
entry = entries.get(name)
|
||
if not entry:
|
||
errors.append(f"{name}: файла нет в индексе {INDEX}")
|
||
if not SLUG.fullmatch(name):
|
||
errors.append(f"{name}: слаг не kebab-case латиницей")
|
||
if not task["title"]:
|
||
errors.append(f"{name}: нет заголовка H1")
|
||
if not task["priority"]:
|
||
errors.append(f"{name}: нет строки **Приоритет:**")
|
||
elif task["priority"] not in known:
|
||
errors.append(f"{name}: приоритет «{task['priority']}» не совпадает"
|
||
f" ни с одной секцией индекса ({', '.join(sections)})")
|
||
elif entry and entry["section"] and entry["section"].lower() != task["priority"]:
|
||
errors.append(f"{name}: приоритет в файле «{task['priority']}»,"
|
||
f" а в индексе секция «{entry['section']}»")
|
||
if entry and entry["title"] != task["title"]:
|
||
errors.append(f"{name}: заголовок разошёлся\n"
|
||
f" файл: {task['title']}\n"
|
||
f" индекс: {entry['title']}")
|
||
if entry and not entry["hook"]:
|
||
notes.append(f"{name}: строка индекса без хука — по ней не выбрать задачу")
|
||
if task["type"] not in TYPES and task["type"] != PLAIN_TYPE:
|
||
notes.append(f"{name}: тип «{task['type']}» вне словаря"
|
||
f" ({'/'.join(TYPES)} или без префикса)")
|
||
if "<!-- контекст" in task["path"].read_text(encoding="utf-8"):
|
||
notes.append(f"{name}: тело не дописано (остался плейсхолдер add)")
|
||
|
||
for name, entry in entries.items():
|
||
if name not in tasks:
|
||
errors.append(f"{INDEX}:{entry['line']}: ссылка на несуществующий {name}")
|
||
|
||
errors += index_lint(root)
|
||
|
||
# Кладбище: строки-пункты должны совпадать с форматом (его пишет close).
|
||
closed = root / CLOSED
|
||
if closed.is_file():
|
||
for num, line in enumerate(closed.read_text(encoding="utf-8").splitlines(), 1):
|
||
if line.startswith("- ") and not CLOSED_ENTRY.match(line):
|
||
errors.append(f"{CLOSED}:{num}: строка кладбища не по формату"
|
||
f" «- ГГГГ-ММ-ДД `slug` — …»")
|
||
|
||
# Причина у приоритета желательна, но не обязательна. Ругаемся только на
|
||
# частичное покрытие — это дрейф: у части задач причина есть, у части нет.
|
||
# Ноль из N — осознанный отказ проекта от причин, не расхождение; горящее на
|
||
# каждом check замечание агент просто научится игнорировать.
|
||
with_reason = sum(1 for t in tasks.values() if t["reason"])
|
||
if 0 < with_reason < len(tasks):
|
||
notes.append(f"причина у приоритета есть у {with_reason} из {len(tasks)}"
|
||
f" — либо у всех, либо ни у кого: вперемешку это дрейф")
|
||
|
||
print(f"беклог: {root}, задач {len(tasks)}, строк индекса {len(entries)},"
|
||
f" секций {len(sections)}")
|
||
health(root, tasks, sections)
|
||
for e in errors:
|
||
print(f"ОШИБКА {e}")
|
||
for n in notes:
|
||
print(f"замечание {n}")
|
||
if errors:
|
||
print(f"\nрасхождений: {len(errors)}")
|
||
return 1
|
||
print("\nиндекс согласован" + (f", замечаний: {len(notes)}" if notes else ""))
|
||
return 0
|
||
|
||
|
||
def health(root: Path, tasks: dict[str, dict], sections: list[str]) -> None:
|
||
"""Метрики здоровья беклога: размер секций и число давно неподвижных задач.
|
||
Механизирует правило «беклог гниёт со стороны пополнения» — раньше оно
|
||
держалось только на дисциплине."""
|
||
by_section = {s.lower(): 0 for s in sections}
|
||
for t in tasks.values():
|
||
if t["priority"] in by_section:
|
||
by_section[t["priority"]] += 1
|
||
sizes = ", ".join(f"{s} {by_section[s.lower()]}" for s in sections)
|
||
print(f" секции: {sizes}")
|
||
|
||
dates = touched_map(root)
|
||
if not dates:
|
||
return
|
||
cutoff = (datetime.date.today() - datetime.timedelta(days=STALE_DAYS)).isoformat()
|
||
stale = sum(1 for t in tasks.values()
|
||
if (d := dates.get(t["path"].name)) and d < cutoff)
|
||
if stale:
|
||
print(f" залежалось (>{STALE_DAYS} дней без правки): {stale}"
|
||
f" — груминг просрочен, начни с `list --stale`")
|
||
|
||
|
||
def list_tasks(root: Path, args: argparse.Namespace) -> int:
|
||
tasks = tasks_of(root)
|
||
_, sections = parse_index(root)
|
||
order = {s.lower(): i for i, s in enumerate(sections)}
|
||
rows = [t for t in tasks.values()
|
||
if (not args.priority or t["priority"] == args.priority.lower())
|
||
and (not args.type or t["type"] == args.type.lower())
|
||
and (not args.tag or args.tag.lower() in t["tags"])]
|
||
|
||
if args.stale:
|
||
dates = touched_map(root)
|
||
for t in rows:
|
||
t["touched"] = dates.get(t["path"].name, "—")
|
||
rows.sort(key=lambda t: (t["touched"] == "—", t["touched"]))
|
||
else:
|
||
rows.sort(key=lambda t: (order.get(t["priority"], 99), t["path"].name))
|
||
|
||
for t in rows:
|
||
touched = f"{t.get('touched', ''):<11}" if args.stale else ""
|
||
kind = "" if t["type"] == PLAIN_TYPE else f"[{t['type']}] "
|
||
print(f"{touched}{t['priority']:<9} {t['path'].stem:<46} {kind}{t['bare']}")
|
||
print(f"\nвсего: {len(rows)}")
|
||
return 0
|
||
|
||
|
||
# --- Мутации: правят файл и индекс заодно, чтобы их нельзя было рассогласовать ---
|
||
|
||
def fail(msg: str) -> int:
|
||
print(f"ошибка: {msg}", file=sys.stderr)
|
||
return 2
|
||
|
||
|
||
def build_meta(priority: str, reason: str, tags: list[str]) -> str:
|
||
s = f"**Приоритет:** {priority}"
|
||
if reason:
|
||
s += f" — {reason}"
|
||
if tags:
|
||
s += " · **Теги:** " + ", ".join(tags)
|
||
return s
|
||
|
||
|
||
def load_index(root: Path) -> list[str]:
|
||
return (root / INDEX).read_text(encoding="utf-8").splitlines()
|
||
|
||
|
||
def save_index(root: Path, lines: list[str]) -> None:
|
||
write_atomic(root / INDEX, "\n".join(lines) + "\n")
|
||
|
||
|
||
def section_headers(lines: list[str]) -> list[tuple[int, str]]:
|
||
return [(i, m.group(1)) for i, l in enumerate(lines) if (m := SECTION.match(l))]
|
||
|
||
|
||
def find_section(lines: list[str], priority: str) -> tuple[int | None, str]:
|
||
for i, name in section_headers(lines):
|
||
if name.lower() == priority.lower():
|
||
return i, name
|
||
return None, ""
|
||
|
||
|
||
def find_entry_index(lines: list[str], slug: str) -> int | None:
|
||
for i, l in enumerate(lines):
|
||
m = INDEX_ENTRY.match(l)
|
||
if m and m.group(2) == f"{slug}.md":
|
||
return i
|
||
return None
|
||
|
||
|
||
def insert_entry(lines: list[str], section: str, entry: str) -> None:
|
||
"""Вставляет строку в конец секции (перед следующим ## или концом файла)."""
|
||
hi, _ = find_section(lines, section)
|
||
end = next((j for j in range(hi + 1, len(lines)) if SECTION.match(lines[j])), len(lines))
|
||
ins = end
|
||
while ins - 1 > hi and not lines[ins - 1].strip():
|
||
ins -= 1
|
||
lines.insert(ins, entry)
|
||
|
||
|
||
def update_priority(path: Path, priority: str, new_reason: str | None) -> bool:
|
||
"""Хирургически меняет только поле **Приоритет:** в мета-строке файла,
|
||
сохраняя теги, регистр и любые нераспознанные поля. Возвращает False, если
|
||
мета-строки нет (тогда правку делать нельзя — вызывающий падает)."""
|
||
flines = path.read_text(encoding="utf-8").splitlines()
|
||
mi = next((i for i in range(1, len(flines)) if flines[i].strip()), None)
|
||
if mi is None or not META_FIELD.match(flines[mi].strip()):
|
||
return False
|
||
chunks = flines[mi].split("·")
|
||
for idx, chunk in enumerate(chunks):
|
||
f = META_FIELD.match(chunk.strip())
|
||
if not (f and f.group(1).strip().lower() in ("приоритет", "priority")):
|
||
continue
|
||
_, _, old_reason = (p.strip() for p in f.group(2).partition("—"))
|
||
reason = new_reason if new_reason is not None else old_reason
|
||
field = f"**Приоритет:** {priority}" + (f" — {reason}" if reason else "")
|
||
lead = chunk[:len(chunk) - len(chunk.lstrip())]
|
||
trail = chunk[len(chunk.rstrip()):]
|
||
chunks[idx] = lead + field + trail
|
||
write_atomic(path, "\n".join((*flines[:mi], "·".join(chunks), *flines[mi + 1:])) + "\n")
|
||
return True
|
||
return False
|
||
|
||
|
||
def apply_fixes(root: Path) -> list[str]:
|
||
"""Детерминированная починка дрейфа индекса. Чинит только безопасное, где
|
||
истина однозначно в файле: дубли строк, рассинхрон заголовка, задача не в
|
||
своей секции, отсутствующая строка. Неоднозначное (ссылка на исчезнувший
|
||
файл, битые строки, неизвестный приоритет) не трогает — это на суд человека."""
|
||
fixed: list[str] = []
|
||
lines = load_index(root)
|
||
tasks = tasks_of(root)
|
||
|
||
# 1. Дубли строк на один файл — оставляем первую.
|
||
seen: set[str] = set()
|
||
deduped: list[str] = []
|
||
for l in lines:
|
||
m = INDEX_ENTRY.match(l)
|
||
if m and m.group(2) in seen:
|
||
fixed.append(f"убран дубль строки {m.group(2)}")
|
||
continue
|
||
if m:
|
||
seen.add(m.group(2))
|
||
deduped.append(l)
|
||
lines = deduped
|
||
|
||
# 2. Заголовок в индексе разошёлся с H1 — истина в файле, хук сохраняем.
|
||
for i, l in enumerate(lines):
|
||
m = INDEX_ENTRY.match(l)
|
||
if not m:
|
||
continue
|
||
task = tasks.get(m.group(2))
|
||
if task and m.group(1) != task["title"]:
|
||
hook = (m.group(3) or "").strip()
|
||
lines[i] = f"- [{task['title']}]({m.group(2)})" + (f" — {hook}" if hook else "")
|
||
fixed.append(f"заголовок синхронизирован с файлом: {m.group(2)}")
|
||
|
||
# 3. Задача не в своей секции / нет строки вовсе.
|
||
for name, task in tasks.items():
|
||
if not task["priority"]:
|
||
continue
|
||
hi, section = find_section(lines, task["priority"])
|
||
if hi is None:
|
||
continue # приоритет не совпадает ни с одной секцией — не наше дело
|
||
ei = find_entry_index(lines, task["path"].stem)
|
||
if ei is None:
|
||
insert_entry(lines, section, f"- [{task['title']}]({name})")
|
||
fixed.append(f"добавлена строка индекса без хука: {name}")
|
||
continue
|
||
cur = next((SECTION.match(lines[j]).group(1)
|
||
for j in range(ei, -1, -1) if SECTION.match(lines[j])), None)
|
||
if cur and cur.lower() != section.lower():
|
||
insert_entry(lines, section, lines.pop(ei))
|
||
fixed.append(f"перенесена в секцию «{section}»: {name}")
|
||
|
||
if fixed:
|
||
save_index(root, lines)
|
||
return fixed
|
||
|
||
|
||
def cmd_add(root: Path, a: argparse.Namespace) -> int:
|
||
for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_line(a.hook, "хук"),
|
||
bad_line(a.tag, "теги"), bad_reason(a.reason)):
|
||
if err:
|
||
return fail(err)
|
||
if not a.title.strip():
|
||
return fail("пустой заголовок")
|
||
path = root / f"{a.slug}.md"
|
||
if path.exists():
|
||
return fail(f"{path.name} уже существует — дедуп: допиши в него, а не заводи новый")
|
||
lines = load_index(root)
|
||
if find_entry_index(lines, a.slug) is not None:
|
||
return fail(f"строка индекса для {a.slug} уже есть")
|
||
hi, section = find_section(lines, a.priority)
|
||
if hi is None:
|
||
avail = ", ".join(n for _, n in section_headers(lines))
|
||
return fail(f"нет секции приоритета «{a.priority}» (есть: {avail})")
|
||
kind = a.type.strip().lower() if a.type else ""
|
||
title_full = f"[{kind}] {a.title}" if kind else a.title
|
||
tags = [t.strip() for t in (a.tag or "").split(",") if t.strip()]
|
||
meta = build_meta(section, a.reason or "", tags)
|
||
body = "<!-- контекст, принятые решения, шаги, ссылки на спеки/ADR -->"
|
||
write_atomic(path, f"# {title_full}\n\n{meta}\n\n{body}\n")
|
||
entry = f"- [{title_full}]({a.slug}.md)" + (f" — {a.hook}" if a.hook else "")
|
||
insert_entry(lines, section, entry)
|
||
save_index(root, lines)
|
||
print(f"создано: {a.slug}.md в секции «{section}»; допиши тело редактором")
|
||
if not a.hook:
|
||
print(f" без хука — задай: backlog.py edit {a.slug} --hook …")
|
||
return 0
|
||
|
||
|
||
def cmd_edit(root: Path, a: argparse.Namespace) -> int:
|
||
for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_line(a.hook, "хук")):
|
||
if err:
|
||
return fail(err)
|
||
if a.title is None and a.hook is None and a.type is None:
|
||
return fail("нечего менять: дай --title, --hook или --type")
|
||
path = root / f"{a.slug}.md"
|
||
if not path.exists():
|
||
return fail(f"{a.slug}.md не найден")
|
||
lines = load_index(root)
|
||
ei = find_entry_index(lines, a.slug)
|
||
if ei is None:
|
||
return fail(f"строки индекса для {a.slug} нет")
|
||
task = parse_task(path)
|
||
if a.title is not None and not a.title.strip():
|
||
return fail("пустой заголовок")
|
||
bare = a.title if a.title is not None else task["bare"]
|
||
kind = task["type"] if a.type is None else a.type.strip().lower()
|
||
prefix = "" if kind in ("", PLAIN_TYPE) else f"[{kind}] "
|
||
h1 = f"{prefix}{bare}"
|
||
flines = path.read_text(encoding="utf-8").splitlines()
|
||
if not flines or not flines[0].startswith("#"):
|
||
return fail(f"{a.slug}.md без заголовка H1 — прогони check")
|
||
flines[0] = f"# {h1}"
|
||
write_atomic(path, "\n".join(flines) + "\n")
|
||
m = INDEX_ENTRY.match(lines[ei])
|
||
hook = a.hook if a.hook is not None else (m.group(3) or "").strip()
|
||
lines[ei] = f"- [{h1}]({a.slug}.md)" + (f" — {hook}" if hook else "")
|
||
save_index(root, lines)
|
||
print(f"{a.slug}: обновлено (заголовок/хук/тип)")
|
||
return 0
|
||
|
||
|
||
def cmd_move(root: Path, a: argparse.Namespace) -> int:
|
||
for err in (bad_slug(a.slug), bad_reason(a.reason)):
|
||
if err:
|
||
return fail(err)
|
||
path = root / f"{a.slug}.md"
|
||
if not path.exists():
|
||
return fail(f"{a.slug}.md не найден")
|
||
lines = load_index(root)
|
||
ei = find_entry_index(lines, a.slug)
|
||
if ei is None:
|
||
return fail(f"строки индекса для {a.slug} нет")
|
||
hi, section = find_section(lines, a.priority)
|
||
if hi is None:
|
||
avail = ", ".join(n for _, n in section_headers(lines))
|
||
return fail(f"нет секции приоритета «{a.priority}» (есть: {avail})")
|
||
if not update_priority(path, section, a.reason):
|
||
return fail(f"{a.slug}.md без мета-строки **Приоритет:** — прогони check и почини")
|
||
entry = lines.pop(ei)
|
||
insert_entry(lines, section, entry)
|
||
save_index(root, lines)
|
||
print(f"{a.slug}: перенесено в «{section}»")
|
||
return 0
|
||
|
||
|
||
def cmd_close(root: Path, a: argparse.Namespace) -> int:
|
||
for err in (bad_slug(a.slug), bad_reason(a.reason)):
|
||
if err:
|
||
return fail(err)
|
||
path = root / f"{a.slug}.md"
|
||
if not path.exists():
|
||
return fail(f"{a.slug}.md не найден")
|
||
lines = load_index(root)
|
||
ei = find_entry_index(lines, a.slug)
|
||
if ei is None:
|
||
return fail(f"строки индекса для {a.slug} нет")
|
||
task = parse_task(path)
|
||
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}"
|
||
f" Был приоритет: {task['priority'] or '—'}.")
|
||
closed = root / CLOSED
|
||
prev = closed.read_text(encoding="utf-8") if closed.exists() else "# Кладбище беклога\n"
|
||
if not prev.endswith("\n"):
|
||
prev += "\n"
|
||
write_atomic(closed, prev + bullet + "\n")
|
||
# Порядок: индекс без строки → потом unlink. Обратный порядок оставил бы в
|
||
# индексе ссылку в никуда, если бы unlink упал.
|
||
lines.pop(ei)
|
||
save_index(root, lines)
|
||
path.unlink()
|
||
print(f"{a.slug}: {'на кладбище + удалено' if a.reason else 'удалено (реализовано)'}")
|
||
return 0
|
||
|
||
|
||
def cmd_init(root: Path, a: argparse.Namespace) -> int:
|
||
if not dir_within_cwd(root):
|
||
return fail(f"--dir вне рабочего каталога: {root}")
|
||
index = root / INDEX
|
||
if index.exists():
|
||
return fail(f"{index} уже есть — беклог заведён")
|
||
sections, seen = [], set()
|
||
for s in (s.strip() for s in a.sections.split(",")):
|
||
if s and s.lower() not in seen:
|
||
sections.append(s)
|
||
seen.add(s.lower())
|
||
if not sections:
|
||
return fail("пустой список секций")
|
||
root.mkdir(parents=True, exist_ok=True)
|
||
preamble = ("# Беклог\n\n"
|
||
"Одна задача = один файл `<slug>.md` + строка в этом индексе.\n"
|
||
"Приоритет — грубая оценка «ценность / стоимость». Спекулятивные\n"
|
||
"задачи помечены `[idea]` в заголовке. Ведётся скиллом `backlog`.\n\n")
|
||
write_atomic(index, preamble + "".join(f"## {s}\n\n" for s in sections))
|
||
closed = root / CLOSED
|
||
if not closed.exists():
|
||
write_atomic(closed,
|
||
"# Кладбище беклога\n\n"
|
||
"Задачи, покинувшие беклог без реализации. Пишется `backlog.py close`.\n\n"
|
||
"<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Был приоритет: … -->\n")
|
||
print(f"беклог заведён: {root} (секции: {', '.join(sections)})")
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(prog="backlog.py")
|
||
sub = ap.add_subparsers(dest="command", required=True)
|
||
|
||
p = sub.add_parser("check", help="согласованность файлов и индекса")
|
||
p.add_argument("--dir")
|
||
p.add_argument("--fix", action="store_true",
|
||
help="починить безопасный дрейф (секция, заголовок, дубли)")
|
||
|
||
p = sub.add_parser("list", help="список задач")
|
||
p.add_argument("--dir")
|
||
p.add_argument("--stale", action="store_true")
|
||
p.add_argument("--priority")
|
||
p.add_argument("--type")
|
||
p.add_argument("--tag")
|
||
|
||
p = sub.add_parser("add", help="создать задачу")
|
||
p.add_argument("--dir")
|
||
p.add_argument("--slug", required=True)
|
||
p.add_argument("--title", required=True)
|
||
p.add_argument("--priority", required=True)
|
||
p.add_argument("--type", choices=TYPES)
|
||
p.add_argument("--hook")
|
||
p.add_argument("--reason")
|
||
p.add_argument("--tag")
|
||
|
||
p = sub.add_parser("edit", help="сменить заголовок/хук/тип")
|
||
p.add_argument("slug")
|
||
p.add_argument("--title")
|
||
p.add_argument("--hook")
|
||
p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE))
|
||
p.add_argument("--dir")
|
||
|
||
p = sub.add_parser("move", help="перенести в другую секцию приоритета")
|
||
p.add_argument("slug")
|
||
p.add_argument("--priority", required=True)
|
||
p.add_argument("--reason")
|
||
p.add_argument("--dir")
|
||
|
||
p = sub.add_parser("close", help="закрыть задачу (кладбище или удаление)")
|
||
p.add_argument("slug")
|
||
g = p.add_mutually_exclusive_group(required=True)
|
||
g.add_argument("--reason", help="причина отказа → строка на кладбище")
|
||
g.add_argument("--implemented", action="store_true", help="реализовано → просто удалить")
|
||
p.add_argument("--dir")
|
||
|
||
p = sub.add_parser("init", help="завести пустой беклог")
|
||
p.add_argument("--dir")
|
||
p.add_argument("--sections", default="высокий,средний,низкий")
|
||
|
||
a = ap.parse_args()
|
||
if a.command == "init":
|
||
return cmd_init(Path(a.dir or "docs/backlog"), a)
|
||
root = resolve_dir(a.dir)
|
||
dispatch = {
|
||
"check": lambda: check(root, a.fix),
|
||
"list": lambda: list_tasks(root, a),
|
||
"add": lambda: cmd_add(root, a),
|
||
"edit": lambda: cmd_edit(root, a),
|
||
"move": lambda: cmd_move(root, a),
|
||
"close": lambda: cmd_close(root, a),
|
||
}
|
||
return dispatch[a.command]()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|