Files
dev-skills/av-dev-backlog/skills/backlog/scripts/backlog.py
T
avandClaude Opus 4.8 074c6f3448 backlog: переименовать плагин в av-dev-backlog, скилл — в backlog
Соглашение об именах: длинное имя плагина с префиксом 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>
2026-07-24 08:52:16 +03:00

707 lines
34 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())