#!/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 "" 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" "Одна задача = один файл `.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" "\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())