Files
dev-skills/av-dev-pm/skills/tasks/scripts/tasks.py
T
av 9cef45252c av-dev-pipeline: бриф удалён, проходы читают документы канона напрямую
- удалены скилл project-brief и контракт брифа; вместо них references/
  project-facts.md — карта «что нужно проходу → где лежит» и таблица
  поразрядной деградации по документам
- девять charter'ов, review-pipeline, task-pipeline и task-batch переписаны
  на пути канона; OpenSpec стал объявленной предпосылкой без ветки деградации
- шаг синка документации переписан в построчный доклад, закрытие задачи —
  вызовом скилла av-dev-pm:tasks вместо строки-слота из CLAUDE.md
- по находкам ревью: docs.py звал tasks.py из чужого каталога и выдавал его
  отказ окружения за дрейф; сверка миграций не видела рабочее дерево;
  плейсхолдер краснел вместо замечания; сверка capability проходила по
  совпадению с именем пакета; tasks.py не читал docs/.pm.json; скилл docs
  пересказывал канон в пяти местах
2026-08-03 14:28:55 +03:00

2450 lines
128 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
"""Детерминированный инструмент управления задачами: файлы против индексов.
Преемник backlog.py. Разница по существу одна: **порядка нет, есть цель**.
Приоритеты и секция-как-уровень заменены на цель (`goal:<слаг>` тегом) и на
спринт — замороженный набор задач под одну цель. Индексов теперь четыре, и
задача живёт ровно в одном из них за раз.
Раскладка (все имена настраиваются, см. init и `.tasks.json`):
<tasks>/
items/ задачи и цели файлами, <slug>.md
PLAN.md оглавление целей: линия (упорядоченная) и кусты
BACKLOG.md что можно взять — только задачи, целей здесь нет
SPRINT.md текущий спринт: цель, набор, дата, слаг
REJECTED.md ушедшее БЕЗ реализации, с причиной и датой
Источник истины — файл задачи в items/. Индексы производны: расходятся —
неправ индекс. **Хук живёт в мета-строке файла**, а не только в строке индекса:
иначе восстановление пропавшей строки (`check --fix`) теряло бы его навсегда.
Исключение из производности одно и оно намеренное: **в каком индексе лежит
задача — знают индексы**, потому что «в спринте» это свойство спринта, а не
задачи; поля-состояния в файле нет, а рассогласование ловит check.
Тип — ключевое слово (goal | idea | epic | task), по-английски, как и прочие
токены команд. Обычная задача (task) префикса не несёт, остальные кодируются
префиксом `[goal]`/`[idea]`/`[epic]` в заголовке H1. Отдельного поля типа нет.
`[goal]` и `[epic]` — разные вещи: цель постоянна и живёт, пока живёт
направление; эпик временен — это задача, которая не мерджится целиком, её
разбирают, и он исчезает.
Использование:
tasks.py init [--dir DIR] [--sections …] [--plan-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]
tasks.py add --slug S --title T [--type goal|idea|epic] [--section S]
[--goal G] [--hook H] [--reason R] [--tag a,b] [--dir DIR]
tasks.py edit S [--title T] [--hook H] [--type T] [--goal G]
[--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]
tasks.py reopen S [--reason R] [--dir DIR]
tasks.py sprint start --goal S [--date ГГГГ-ММ-ДД] [--slug S] [--dir DIR]
tasks.py sprint take S [S …] [--dir DIR]
tasks.py sprint drop S [S …] --reason R [--dir DIR]
tasks.py sprint close [--dissolve --reason R] [--dir DIR]
tasks.py adopt scan --from PATH [PATH …] [--target DIR] [--out PLAN.json]
tasks.py adopt apply --plan PLAN.json [--refs PATH …] [--dry-run]
Каталог задач ищется так: `--dir` → указатель в `CLAUDE.md` проекта (его читает
агент и передаёт `--dir`) → `.tasks.json` вверх от текущего каталога → умолчания
(`docs/tasks`, `tasks`, `doc/tasks`) вверх от текущего каталога.
Коды выхода (единый словарь, на нём ветвятся скиллы):
0 всё сошлось / операция выполнена
1 расхождения найдены (только check: дрейф индексов и файлов)
2 ошибка употребления: неверные аргументы, нарушенное правило процесса
3 окружение: каталог задач не найден, конфиг битый или указывает в никуда
4 внутренний сбой (непойманное исключение) — это дефект скрипта
Тело задачи (контекст, критерии, вопросы, ссылки) остаётся агенту — add кладёт
заголовок, мета-строку и шаблон-плейсхолдер; агент дописывает редактором.
Границы безопасности: слаг — только латиница kebab-case (traversal невозможен),
--dir обязан быть внутри рабочего каталога, в заголовок/хук/причину не пролезет
перевод строки, `·` в хуке и причине запрещён (это разделитель мета-полей).
Все проверки идут до первой записи; запись — одним проходом (см. Plan).
Язык не зашит инструментально: секции сопоставляются с заголовками индексов как
есть, имена служебных файлов и заголовков настраиваются. Текст задач — русский.
"""
import argparse
import datetime
import json
import os
import re
import subprocess
import sys
from pathlib import Path
CONFIG_NAME = ".tasks.json" # прежний дом настроек, читается для совместимости
PM_CONFIG_REL = "../.pm.json" # текущий дом: docs/.pm.json, ключ "tasks"
EXIT_OK = 0
EXIT_DRIFT = 1
EXIT_USAGE = 2
EXIT_ENV = 3
EXIT_INTERNAL = 4
DEFAULTS = {
"items": "items",
"backlog": "BACKLOG.md",
"plan": "PLAN.md",
"sprint": "SPRINT.md",
"rejected": "REJECTED.md",
"sprint_section": "Набор",
"criteria_heading": "Критерии приёмки",
"questions_heading": "Вопросы",
"oracle_word": "оракул",
}
# Какие ключи конфига — имена файлов и каталогов (их существование сверяется
# с диском первым делом, иначе кривой ключ выглядит как пропавший файл).
PATH_KEYS = ("items", "backlog", "plan", "sprint", "rejected")
DEFAULT_SECTIONS = "ядро,инфра"
DEFAULT_PLAN_SECTIONS = "линия,кусты"
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")
GOAL_LINE = re.compile(r"^\*\*(?:Цель|Goal):\*\*\s*\[(.+?)\]\((.+?\.md)\)")
SPRINT_SLUG_LINE = re.compile(r"\*\*(?:Спринт|Sprint):\*\*\s*`?([a-z0-9][a-z0-9.-]*)`?")
DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}")
BULLET = re.compile(r"^[-*]\s+(.*)$")
# Строка кладбища: - ГГГГ-ММ-ДД `slug` — текст
REJECTED_ENTRY = re.compile(r"^- \d{4}-\d{2}-\d{2} `[a-z0-9-]+` — .+")
GOAL = "goal"
TYPES = ("goal", "idea", "epic") # непустые типы-ключевые слова, префикс [..] в H1
PLAIN_TYPE = "task" # обычная задача — без префикса
TAKEABLE = (PLAIN_TYPE,) # что вообще можно взять в спринт
QUESTION_TAG = "question"
GOAL_TAG = "goal:"
SPRINT_TAG = "sprint:"
DECOMPOSED_TAG = "decomposed" # цель разложена на задачи (см. «Статус цели»)
STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check
CRITERIA_MIN, CRITERIA_MAX = 2, 5 # сколько утверждений в критериях приёмки
BODY_PLACEHOLDER = "<!-- "
BLOCKER_SECTIONS = ("блокеры", "блокер", "blockers", "blocked")
class Usage(Exception):
"""Нарушение правила или неверный ввод — сообщение адресовано вызывающему."""
class Env(Exception):
"""Каталог задач или его конфиг непригодны — чинится не аргументами."""
# --- Валидация недоверенного ввода (аргументы могут прийти из текста задачи) ---
def bad_line(value: str | None, 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_meta_text(value: str | None, field: str) -> str | None:
"""Причина и хук едут в мета-строку, где `·` — разделитель полей."""
if value is None:
return None
if (e := bad_line(value, field)):
return e
if "·" in value:
return f"{field}: символ · зарезервирован под разделитель мета-полей"
return None
def bad_reason(reason: str | None) -> str | None:
return bad_meta_text(reason, "причина")
def bad_hook(hook: str | None) -> str | None:
return bad_meta_text(hook, "хук")
def bad_tags(raw: str | None) -> str | None:
if raw is None:
return None
if (e := bad_line(raw, "теги")):
return e
for t in split_tags(raw):
if not re.fullmatch(r"[a-z0-9]+(?:[-:.][a-z0-9]+)*", t):
return f"тег «{t}» — латиница, цифры и разделители -:. (например goal:merge-robustness)"
return None
def split_tags(raw: str | None) -> list[str]:
return [t.strip().lower() for t in (raw or "").split(",") if t.strip()]
def dir_within_cwd(root: Path) -> bool:
try:
root.resolve().relative_to(Path.cwd().resolve())
return True
except ValueError:
return False
# --- Запись: сперва все проверки, потом один проход ---
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)
class Plan:
"""Намерения записи, собранные до первой записи.
Правило одно: **мутация сперва проверяет всё и складывает правки сюда, и
только когда ни одна проверка не отказала, зовёт `commit`.** Иначе отказ на
втором слаге оставляет первый файл переписанным при нетронутых индексах —
задача числится в спринте и одновременно объясняет причиной, почему из него
вышла.
`commit` пишет в два такта: сперва все временные файлы (тут и происходит
ввод-вывод, тут же и все возможные отказы), потом переименования подряд.
Полной транзакции на несколько файлов POSIX не даёт, но окно рассогласования
сжимается до цепочки rename без ввода-вывода; а поскольку **индексы
производны от файлов**, порванная цепочка чинится `check --fix` без потерь.
"""
def __init__(self) -> None:
self.writes: list[tuple[Path, str]] = []
self.deletes: list[Path] = []
def file(self, path: Path, text: str) -> None:
text = text if text.endswith("\n") else text + "\n"
for i, (p, _) in enumerate(self.writes):
if p == path: # один файл — одна запись, побеждает последняя
self.writes[i] = (path, text)
return
self.writes.append((path, text))
def index(self, lay: "Layout", kind: str, lines: list[str]) -> None:
self.file(lay.index(kind), "\n".join(lines))
def delete(self, path: Path) -> None:
self.deletes.append(path)
def commit(self) -> None:
staged: list[tuple[Path, Path]] = []
for path, text in self.writes:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(text, encoding="utf-8")
staged.append((tmp, path))
for tmp, path in staged:
os.replace(tmp, path)
for path in self.deletes:
path.unlink(missing_ok=True)
class Layout:
"""Каталог задач и имена его частей. Всё настраивается: у соседнего проекта
может быть другой подкаталог и другие имена индексов, а семантика та же."""
def __init__(self, root: Path, cfg: dict):
self.root = root
self.cfg = {**DEFAULTS, **cfg}
self.items = root / self.cfg["items"]
def index(self, kind: str) -> Path:
return self.root / self.cfg[kind]
def name(self, kind: str) -> str:
return self.cfg[kind]
@property
def indexes(self) -> tuple[str, ...]:
return ("backlog", "sprint", "plan")
def load_config(root: Path) -> dict:
"""Настройки каталога задач.
Дом один — `docs/.pm.json`, ключ `tasks`: один конфиг на весь канон, а не по
одному на каталог. Прежний `<tasks>/.tasks.json` читается, пока живы проекты,
которые ещё не переехали; когда есть оба, побеждает `.pm.json`, и об этом
говорится вслух, потому что молча выбранный из двух конфиг — это дрейф,
который потом никто не объяснит.
"""
pm = (root / PM_CONFIG_REL).resolve()
if pm.is_file():
data = _read_json(pm)
section = data.get("tasks", {})
if not isinstance(section, dict):
raise Env(f"{pm}: ключ «tasks» — ожидался объект с настройками")
if (root / CONFIG_NAME).is_file():
print(f"ЗАМЕЧАНИЕ настройки взяты из {pm}; {root / CONFIG_NAME}"
f" остался от прежней раскладки и не читается — удали его",
file=sys.stderr)
return _validate_config(section, pm)
path = root / CONFIG_NAME
if not path.is_file():
return {}
return _validate_config(_read_json(path), path)
def _read_json(path: Path) -> dict:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise Env(f"{path}: не разбирается как JSON — {e}")
if not isinstance(data, dict):
raise Env(f"{path}: ожидался объект с настройками")
return data
def _validate_config(data: dict, path: Path) -> dict:
unknown = set(data) - set(DEFAULTS)
if unknown:
raise Env(f"{path}: неизвестные ключи: {', '.join(sorted(unknown))}"
f" (известны: {', '.join(sorted(DEFAULTS))})")
for key, value in data.items():
if not isinstance(value, str) or not value.strip():
raise Env(f"{path}: ключ «{key}» — ожидалась непустая строка")
if key in PATH_KEYS and (value.startswith("/") or ".." in Path(value).parts):
raise Env(f"{path}: ключ «{key}» = «{value}» — только имя внутри каталога задач")
return data
def config_problems(lay: Layout) -> list[str]:
"""Каждый путь из конфига сверяется с диском ДО любых выводов о задачах.
Иначе неверный ключ (`items: "tasks"` при каталоге `items/`) заставляет
check обвинять невиновных: «ссылка на несуществующий файл», хотя файл на
месте, а мимо смотрит конфиг.
"""
src = lay.root / CONFIG_NAME
where = str(src) if src.is_file() else "умолчания (файла .tasks.json нет)"
out = []
if not lay.items.is_dir():
out.append(f"{where}: items = «{lay.cfg['items']}» → {lay.items} — каталога нет")
for kind in ("backlog", "plan", "sprint", "rejected"):
p = lay.index(kind)
if not p.is_file():
out.append(f"{where}: {kind} = «{lay.cfg[kind]}» → {p} — файла нет")
return out
def looks_like_tasks(p: Path) -> bool:
if (p / CONFIG_NAME).is_file():
return True
return (p / DEFAULTS["backlog"]).is_file()
def resolve_layout(explicit: str | None) -> Layout:
"""Каталог задач для команд, кроме init.
Цепочка разрешения: явный `--dir` (обязан быть внутри рабочего каталога) →
`.tasks.json` или умолчания вверх от текущего каталога. Указатель в
`CLAUDE.md` проекта — звено между ними, но читает его агент и передаёт
сюда `--dir`: скрипт не разбирает чужую документацию.
"""
if explicit:
root = Path(explicit)
if not dir_within_cwd(root):
raise Env(f"--dir вне рабочего каталога: {explicit}")
if not looks_like_tasks(root):
raise Env(f"задач нет в «{explicit}»;"
f" новый проект — tasks.py init --dir {explicit}")
return Layout(root, load_config(root))
here = Path.cwd().resolve()
for base in (here, *here.parents):
for candidate in (base, base / "docs/tasks", base / "tasks", base / "doc/tasks"):
if looks_like_tasks(candidate):
try:
rel = candidate.relative_to(here)
except ValueError:
rel = candidate
return Layout(rel if str(rel) != "." else candidate, load_config(candidate))
if (base / ".git").exists():
break # выше корня репозитория не ищем
raise Env("каталог задач не найден: ни --dir, ни docs/tasks вверх от"
f" {here}. По канону путь всегда docs/tasks; чужую раскладку"
" переводит скилл av-dev-pm:canon, новый проект —"
" tasks.py init --dir docs/tasks")
# --- Чтение индексов ---
def read_lines(path: Path) -> list[str]:
return path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
def parse_entries(lines: list[str]) -> tuple[dict[str, dict], list[str]]:
"""Строки индекса по имени файла + порядок секций.
Дубли имени файла тут схлопываются (побеждает последний) — их отдельно
ловит index_lint, поэтому опираться на этот dict как на полноту нельзя.
"""
entries: dict[str, dict] = {}
sections: list[str] = []
section = None
for num, line in enumerate(lines, 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[os.path.basename(target)] = {
"title": title, "section": section, "hook": hook,
"line": num, "target": target}
return entries, sections
def index_lint(lines: list[str], label: str) -> list[str]:
"""Структурные дефекты индекса, которых схлопнутый dict не видит: битые
строки-пункты, дубли на один файл, задачи до первой секции."""
errors: list[str] = []
section = None
seen: dict[str, int] = {}
for num, line in enumerate(lines, 1):
if (m := SECTION.match(line)):
section = m.group(1)
continue
if not line.startswith("- ["):
continue
m = INDEX_ENTRY.match(line)
if not m:
errors.append(f"{label}:{num}: строка-пункт не по формату"
f" «- [Заголовок](items/slug.md) — хук»")
continue
target = os.path.basename(m.group(2))
if section is None:
errors.append(f"{label}:{num}: {target} стоит до первой секции")
if target in seen:
errors.append(f"{label}:{num}: дубль строки для {target}"
f" (первая — строка {seen[target]})")
else:
seen[target] = num
return errors
# --- Чтение файлов задач ---
def body_sections(text: str) -> dict[str, str]:
"""Содержимое разделов `## …` тела — по нижнему регистру заголовка.
Комментарии-плейсхолдеры вырезаются: раздел, в котором остался только
подсказочный `<!-- … -->`, считается пустым, иначе шаблон `add` сам себя
засчитывал бы за заполненные критерии.
"""
text = re.sub(r"<!--.*?-->", "", text, flags=re.S)
out: dict[str, list[str]] = {}
cur = None
for line in text.splitlines()[1:]:
if (m := SECTION.match(line)):
cur = m.group(1).strip().lower()
out.setdefault(cur, [])
continue
if cur is not None:
out[cur].append(line)
return {k: "\n".join(v).strip() for k, v in out.items()}
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
if (m := TYPE_PREFIX.match(title)):
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()), "")
section, reason, hook, 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 ("секция", "section"):
section, _, reason = (p.strip() for p in value.partition("—"))
section = section.rstrip(".,").lower()
elif key in ("хук", "hook"):
hook = value
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, "hook": hook, "tags": tags, "goal": goal, "path": path,
"text": text, "body": body_sections(text)}
def tasks_of(lay: Layout) -> dict[str, dict]:
if not lay.items.is_dir():
return {}
return {p.name: parse_task(p) for p in sorted(lay.items.glob("*.md"))}
def home_index(task: dict) -> str:
"""Индекс, которому задача принадлежит по типу. Спринт — исключение: туда
задача попадает не по типу, а решением набора."""
return "plan" if task["type"] == GOAL else "backlog"
def touched_map(lay: Layout) -> dict[str, str]:
"""Дата последнего коммита каждого файла задач — одним вызовом git. Ключ —
имя файла (в items/ имена уникальны). Нет git / нет истории → пустая карта,
вызывающий подставит «—»."""
try:
out = subprocess.run(["git", "log", "--format=%as", "--name-only", "--", str(lay.items)],
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 DATE_RE.fullmatch(line):
cur = line # лог новейшие сверху → первая дата и есть последняя правка
elif cur:
dates.setdefault(os.path.basename(line), cur)
return dates
# --- Критерии приёмки: механизируемая часть ---
def criteria_stats(lay: Layout, body: str) -> tuple[int, int]:
"""(сколько утверждений, у скольких не назван оракул).
Честно механизируется только счёт пунктов. «Назван оракул» проверяется
эвристикой — присутствием слова-маркера (`oracle_word`) в пункте, — и это
именно эвристика: настоящий оракул от слова «оракул» она не отличает.
Поэтому счёт даёт отказ, а оракул — только замечание.
"""
marker = lay.cfg["oracle_word"].lower()
items: list[str] = []
for line in body.splitlines():
if (m := BULLET.match(line)):
items.append(m.group(1))
elif items and line.strip():
items[-1] += " " + line.strip()
return len(items), sum(1 for it in items if marker not in it.lower())
def criteria_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]:
"""Отказы и замечания по критериям приёмки задачи. Общее для check и take."""
crit = lay.cfg["criteria_heading"].lower()
body = task["body"].get(crit, "")
name = task["path"].name
if not body:
return ([f"{name}: нет раздела «{lay.cfg['criteria_heading']}»"
f" — принимать будет не по чему"], [])
n, no_oracle = criteria_stats(lay, body)
errors, notes = [], []
if n < CRITERIA_MIN:
errors.append(f"{name}: критериев приёмки {n}, надо {CRITERIA_MIN}{CRITERIA_MAX}"
f" проверяемых утверждений списком «- …»")
elif n > CRITERIA_MAX:
notes.append(f"{name}: критериев приёмки {n} — больше {CRITERIA_MAX};"
f" это обычно признак, что задача крупнее задачи")
if no_oracle:
notes.append(f"{name}: у {no_oracle} из {n} критериев не назван оракул"
f" (нет слова «{lay.cfg['oracle_word']}») — проверено только наличие"
f" слова, годность оракула машине не видна")
return errors, notes
def questions_open(lay: Layout, task: dict) -> bool:
"""Открытый вопрос — это **непустой раздел**, а не тег.
Тег производен и забывается; отказ по тегу наказывал бы аккуратного и
пропускал забывчивого — стимул ровно обратный записанному правилу.
"""
return bool(task["body"].get(lay.cfg["questions_heading"].lower()))
# --- Спринт ---
def sprint_goal(lay: Layout) -> tuple[str, str]:
"""Слаг и заголовок цели текущего спринта; ('', '') — спринта нет."""
for line in read_lines(lay.index("sprint")):
if (m := GOAL_LINE.match(line.strip())):
return Path(m.group(2)).stem, m.group(1)
return "", ""
def sprint_slug(lay: Layout) -> str:
"""Слаг самого спринта — им метится урожай (`sprint:<слаг>`)."""
for line in read_lines(lay.index("sprint")):
if (m := SPRINT_SLUG_LINE.search(line)):
return m.group(1)
return ""
def sprint_header(lay: Layout, goal_slug: str, goal_title: str,
date: str, slug: str) -> list[str]:
link = f"{lay.cfg['items']}/{goal_slug}.md"
return ["# Спринт", "",
f"**Цель:** [{goal_title}]({link}) · **Начат:** {date}"
f" · **Спринт:** `{slug}`", "",
f"Урожай спринта поднимается `tasks.py list --tag {SPRINT_TAG}{slug}`"
" — это первая порция переоценки на сессии.", ""]
def empty_sprint(lay: Layout) -> str:
return ("# Спринт\n\n"
"Спринта нет. Цель называет человек, набор собирает агент:\n"
"`tasks.py sprint start --goal <слаг>`.\n\n"
f"## {lay.cfg['sprint_section']}\n")
# --- check ---
def check(lay: Layout, fix: bool = False) -> int:
problems = config_problems(lay)
if problems:
print(f"задачи: {lay.root} — конфиг не сходится с диском")
for p in problems:
print(f"КОНФИГ {p}")
print("\nсперва конфиг: пока он мимо, всё остальное диагностируется ложно"
f" (правь {lay.root / CONFIG_NAME} или переименуй файлы)")
return EXIT_ENV
if fix:
fixed, ambiguous = apply_fixes(lay)
for line in fixed:
print(f"ПОЧИНЕНО {line}")
for line in ambiguous:
print(f"НЕОДНОЗНАЧНО {line}")
if fixed or ambiguous:
print()
idx = {k: parse_entries(read_lines(lay.index(k))) for k in lay.indexes}
entries = {k: v[0] for k, v in idx.items()}
sections = {k: v[1] for k, v in idx.items()}
tasks = tasks_of(lay)
errors: list[str] = []
notes: list[str] = []
label = {k: lay.name(k) for k in lay.indexes}
known = {k: {s.lower() for s in sections[k]} for k in lay.indexes}
goal_slugs = {p[:-3] for p, t in tasks.items() if t["type"] == GOAL}
goal_of_sprint, _ = sprint_goal(lay)
ques = lay.cfg["questions_heading"].lower()
for s in sections["backlog"]:
if s.lower() in BLOCKER_SECTIONS:
notes.append(f"{label['backlog']}: секции «{s}» быть не должно —"
f" блокер это состояние, а не полка: он живёт до ответа"
f" человека, а его следы — вопросами в файлах задач")
for name, task in tasks.items():
where = [k for k in lay.indexes if name in entries[k]]
home = home_index(task)
if not SLUG.fullmatch(name):
errors.append(f"{name}: слаг не kebab-case латиницей")
if not task["title"]:
errors.append(f"{name}: нет заголовка H1")
if task["type"] not in TYPES and task["type"] != PLAIN_TYPE:
notes.append(f"{name}: тип «{task['type']}» вне словаря"
f" ({'/'.join(TYPES)} или без префикса)")
# 1. Задача живёт ровно в одном индексе за раз.
allowed = {home} | ({"sprint"} if home == "backlog" else set())
if not where:
errors.append(f"{name}: нет ни в одном индексе (ожидался {label[home]})")
elif len(where) > 1:
errors.append(f"{name}: сразу в нескольких индексах"
f" ({', '.join(label[k] for k in where)}) — задача живёт в одном")
elif where[0] not in allowed:
errors.append(f"{name}: лежит в {label[where[0]]}, а по типу"
f" «{task['type']}» её место в {label[home]}"
f" — перенесёт `check --fix`")
place = where[0] if len(where) == 1 else None
entry = entries[place][name] if place else None
# 2. Секция файла — истина, секция индекса производна. В спринте секция
# файла хранит, куда задачу вернуть, и с «Набором» не сверяется.
if not task["section"]:
errors.append(f"{name}: нет поля **Секция:** в мета-строке")
elif task["section"] not in known[home]:
errors.append(f"{name}: секция «{task['section']}» не совпадает ни с одной"
f" секцией {label[home]} ({', '.join(sections[home])})")
elif place and place != "sprint" and entry["section"] \
and entry["section"].lower() != task["section"]:
errors.append(f"{name}: секция в файле «{task['section']}»,"
f" а в {label[place]} — «{entry['section']}»")
if entry and entry["title"] != task["title"]:
errors.append(f"{name}: заголовок разошёлся\n"
f" файл: {task['title']}\n"
f" индекс: {entry['title']}")
# 2а. Хук: истина в файле, строка индекса производна. Пока хук жил
# только в индексе, восстановление пропавшей строки его теряло.
if entry and task["hook"] and entry["hook"] != task["hook"]:
errors.append(f"{name}: хук разошёлся (истина в файле)\n"
f" файл: {task['hook']}\n"
f" индекс: {entry['hook']}")
elif entry and not task["hook"] and entry["hook"]:
errors.append(f"{name}: хук есть в {label[place]}, а в файле нет —"
f" `check --fix` перенесёт его в мета-строку")
elif not task["hook"]:
notes.append(f"{name}: без хука — по нему выбирают задачу"
f" (`tasks.py edit {name[:-3]} --hook …`)")
# 3. Цель. Связь однонаправленна: задача несёт goal:<слаг>, перечень
# задач цели выводится `list --goal`, а не хранится.
if task["type"] != GOAL:
if not task["goal"]:
if task["type"] == "idea":
notes.append(f"{name}: идея без цели — цель проставляется,"
f" когда идея становится задачей")
else:
errors.append(f"{name}: нет тега {GOAL_TAG}<слаг> — задача вне цели"
f" не попадёт ни в один спринт")
elif task["goal"] not in goal_slugs:
errors.append(f"{name}: тег {GOAL_TAG}{task['goal']} указывает на цель,"
f" которой нет в {lay.cfg['items']}/")
# 4. Спринт: задача с открытым вопросом в набор не берётся, и судит об
# этом раздел, а не тег.
if place == "sprint":
if questions_open(lay, task):
errors.append(f"{name}: в спринте с непустым разделом"
f" «{lay.cfg['questions_heading']}» — вопрос разбирается"
f" до взятия")
elif QUESTION_TAG in task["tags"]:
errors.append(f"{name}: в спринте с тегом «{QUESTION_TAG}» —"
f" либо вопрос открыт и задача выходит, либо тег снимается")
if task["type"] not in TAKEABLE:
errors.append(f"{name}: в спринте тип «{task['type']}»"
f" — берутся только задачи")
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
# 5. Тег «question» производен от раздела: раздел — факт, тег — метка.
has_q_section = questions_open(lay, task)
if has_q_section and QUESTION_TAG not in task["tags"]:
notes.append(f"{name}: раздел «{lay.cfg['questions_heading']}» непуст,"
f" а тега «{QUESTION_TAG}» нет — отбор `list --questions`"
f" её не увидит (`edit {name[:-3]} --add-tag {QUESTION_TAG}`)")
if QUESTION_TAG in task["tags"] and not has_q_section:
notes.append(f"{name}: тег «{QUESTION_TAG}» есть, а раздела"
f" «{lay.cfg['questions_heading']}» нет — снять тег?")
# 6. Цель: «не разобрана» и «всё закрыто» обязаны различаться.
if task["type"] == GOAL:
mine = sum(1 for t in tasks.values() if t["goal"] == name[:-3])
if not mine and DECOMPOSED_TAG not in task["tags"]:
notes.append(f"{name}: цель без задач и без тега «{DECOMPOSED_TAG}» —"
f" «ещё не разобрана» и «всё закрыто» неразличимы;"
f" тег ставится, когда цель разложена на задачи")
elif not mine:
notes.append(f"{name}: цель разобрана, а открытых задач не осталось —"
f" закрывай (`tasks.py close {name[:-3]} --implemented`)")
if BODY_PLACEHOLDER in task["text"]:
notes.append(f"{name}: тело не дописано (остался плейсхолдер add)")
for kind in lay.indexes:
for name, entry in entries[kind].items():
if name not in tasks:
errors.append(f"{label[kind]}:{entry['line']}: ссылка на несуществующий"
f" {lay.cfg['items']}/{name}")
errors += index_lint(read_lines(lay.index(kind)), label[kind])
if goal_of_sprint and goal_of_sprint + ".md" not in tasks:
errors.append(f"{label['sprint']}: цель «{goal_of_sprint}» не найдена"
f" в {lay.cfg['items']}/")
if not goal_of_sprint and entries["sprint"]:
errors.append(f"{label['sprint']}: набор есть, а цель не названа")
if goal_of_sprint and not sprint_slug(lay):
errors.append(f"{label['sprint']}: у спринта нет слага (**Спринт:** `…`) —"
f" урожай не отобрать; перезапусти `sprint start`")
rejected = lay.index("rejected")
if rejected.is_file():
for num, line in enumerate(read_lines(rejected), 1):
if line.startswith("- ") and not REJECTED_ENTRY.match(line):
errors.append(f"{lay.name('rejected')}:{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" либо у всех, либо ни у кого: вперемешку это дрейф,"
f" а на причине держится всё, что переживает запись")
print(f"задачи: {lay.root}, файлов {len(tasks)}, строк индексов "
+ ", ".join(f"{label[k]} {len(entries[k])}" for k in lay.indexes))
health(lay, tasks, entries, sections)
for e in errors:
print(f"ОШИБКА {e}")
for n in notes:
print(f"замечание {n}")
if errors:
print(f"\nрасхождений: {len(errors)}")
return EXIT_DRIFT
print("\nиндексы согласованы" + (f", замечаний: {len(notes)}" if notes else ""))
return EXIT_OK
def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None:
"""Метрики здоровья: размер секций, спринт, открытые вопросы, залежалость,
цели без задач. Механизирует то, что иначе держится на дисциплине."""
backlog = [t for n, t in tasks.items() if n in entries["backlog"]]
by_section = {s.lower(): 0 for s in sections["backlog"]}
for t in backlog:
if t["section"] in by_section:
by_section[t["section"]] += 1
if by_section:
print(" беклог: " + ", ".join(f"{s} {by_section[s.lower()]}" for s in sections["backlog"]))
goal_slug, _ = sprint_goal(lay)
if goal_slug:
print(f" спринт: цель «{goal_slug}», слаг «{sprint_slug(lay) or '—'}»,"
f" задач {len(entries['sprint'])}")
else:
print(" спринт: не начат")
questions = [n for n, t in tasks.items() if questions_open(lay, t)]
if questions:
print(f" с открытым вопросом: {len(questions)}"
f" — в спринт не берутся, разбор первым шагом сессии")
goals = {n[:-3]: t for n, t in tasks.items() if t["type"] == GOAL}
if goals:
counts = {g: sum(1 for t in tasks.values() if t["goal"] == g) for g in goals}
empty = [g for g, c in counts.items() if c == 0]
undecomposed = [g for g in empty if DECOMPOSED_TAG not in goals[g]["tags"]]
print(f" цели: {len(goals)}, из них без открытых задач {len(empty)}"
+ (f" ({', '.join(sorted(empty))})" if empty else "")
+ (f"; не разобрано {len(undecomposed)}" if undecomposed else ""))
dates = touched_map(lay)
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`")
# --- list ---
def list_tasks(lay: Layout, a: argparse.Namespace) -> int:
if (err := bad_tags(a.tag)):
raise Usage(err)
tasks = tasks_of(lay)
entries = {k: parse_entries(read_lines(lay.index(k)))[0] for k in lay.indexes}
order = {}
for k in lay.indexes:
for i, s in enumerate(parse_entries(read_lines(lay.index(k)))[1]):
order.setdefault(s.lower(), i)
def place(name: str) -> str:
for k in lay.indexes:
if name in entries[k]:
return k
return "—"
wanted = split_tags(a.tag) # список: отбираются задачи со ВСЕМИ тегами
known_tags = {t for task in tasks.values() for t in task["tags"]}
rows = []
for name, t in tasks.items():
t["place"] = place(name)
if a.index and a.index != "all" and t["place"] != a.index:
continue
if a.section and t["section"] != a.section.lower():
continue
if a.type and t["type"] != a.type.lower():
continue
if wanted and not set(wanted) <= set(t["tags"]):
continue
if a.goal and t["goal"] != a.goal.lower():
continue
if a.questions and not questions_open(lay, t):
continue
rows.append(t)
if a.stale:
dates = touched_map(lay)
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["section"], 99), t["path"].name))
for t in rows:
touched = f"{t.get('touched', ''):<11}" if a.stale else ""
kind = "" if t["type"] == PLAIN_TYPE else f"[{t['type']}] "
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"\nвсего: {len(rows)}")
# Пустой ответ обязан объясняться: молчаливый ноль читается как «таких
# задач нет», хотя чаще это опечатка в теге или отбор по И вместо ИЛИ.
if wanted:
missing = [t for t in wanted if t not in known_tags]
if missing:
print(f" тегов нет ни у одной задачи: {', '.join(missing)}"
f" (есть: {', '.join(sorted(known_tags)) or '—'})")
elif len(wanted) > 1:
print(f" отбор по нескольким тегам — это И, а не ИЛИ:"
f" нужны все {len(wanted)} сразу")
return EXIT_OK
# --- Мутации: правят файл и индексы заодно, рассогласовать их вручную нельзя ---
def build_meta(section: str, reason: str, hook: str, tags: list[str]) -> str:
s = f"**Секция:** {section}"
if reason:
s += f" — {reason}"
if hook:
s += f" · **Хук:** {hook}"
if tags:
s += " · **Теги:** " + ", ".join(tags)
return s
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], name: str) -> tuple[int | None, str]:
for i, s in section_headers(lines):
if s.lower() == (name or "").lower():
return i, s
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 Path(m.group(2)).name == f"{slug}.md":
return i
return None
def locate_all(lay: Layout, slug: str) -> dict[str, tuple[list[str], int]]:
"""Все индексы, где есть строка задачи. Правится **каждый**: иначе задача,
случайно попавшая в два индекса, чинится наполовину и вторая строка
остаётся со старым заголовком и старым хуком."""
found: dict[str, tuple[list[str], int]] = {}
for kind in lay.indexes:
lines = read_lines(lay.index(kind))
ei = find_entry_index(lines, slug)
if ei is not None:
found[kind] = (lines, ei)
return found
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}» в индексе нет")
end = next((j for j in range(hi + 1, len(lines)) if SECTION.match(lines[j])), len(lines))
if first:
ins = hi + 1
while ins < end and not lines[ins].strip():
ins += 1
elif after:
ai = find_entry_index(lines[hi:end], after)
if ai is None:
raise KeyError(after)
ins = hi + ai + 1
else:
ins = end
while ins - 1 > hi and not lines[ins - 1].strip():
ins -= 1
lines.insert(ins, entry)
def meta_updated(path: Path, section: str | None = None, reason: str | None = None,
hook: str | None = None, tags: list[str] | None = None) -> str | None:
"""Новый текст файла с хирургически поправленной мета-строкой.
Нераспознанные поля и регистр сохраняются. None — мета-строки нет (тогда
правку делать нельзя). Ничего не пишет: запись — дело Plan.
"""
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 None
chunks: list[str | None] = flines[mi].split("·")
seen_section = seen_tags = seen_hook = False
tags_at = None
for idx, chunk in enumerate(chunks):
f = META_FIELD.match((chunk or "").strip())
if not f:
continue
key = f.group(1).strip().lower()
lead = chunk[:len(chunk) - len(chunk.lstrip())]
trail = chunk[len(chunk.rstrip()):]
if key in ("секция", "section") and (section is not None or reason is not None):
old_section, _, old_reason = (p.strip() for p in f.group(2).partition("—"))
sect = section if section is not None else old_section
rsn = reason if reason is not None else old_reason
chunks[idx] = lead + f"**Секция:** {sect}" + (f" — {rsn}" if rsn else "") + trail
seen_section = True
elif key in ("секция", "section"):
seen_section = True
elif key in ("хук", "hook"):
seen_hook = True
if hook is not None:
chunks[idx] = lead + f"**Хук:** {hook}" + trail if hook else None
elif key in ("теги", "tags"):
seen_tags = True
tags_at = idx
if tags is not None:
chunks[idx] = lead + "**Теги:** " + ", ".join(tags) + trail if tags else None
if hook and not seen_hook:
at = tags_at if tags_at is not None else len(chunks)
chunks.insert(at, " **Хук:** " + hook + " ")
if tags and not seen_tags:
chunks.append(" **Теги:** " + ", ".join(tags))
if section is not None and not seen_section:
return None
kept = [c for c in chunks if c is not None]
return "\n".join((*flines[:mi], "·".join(kept).rstrip(), *flines[mi + 1:])) + "\n"
def entry_line(lay: Layout, title: str, slug: str, hook: str) -> str:
link = f"{lay.cfg['items']}/{slug}.md"
return f"- [{title}]({link})" + (f" — {hook}" if hook else "")
def body_template(kind: str, lay: Layout) -> str:
if kind == GOAL:
return ("<!-- зачем эта цель: какое состояние продукта она создаёт -->\n\n"
"## Завершение\n\n"
"<!-- по чему видно, что цель достигнута; задачи здесь НЕ перечисляются"
" — перечень даёт `tasks.py list --goal <слаг>` -->\n")
if kind == "idea":
return ("<!-- что за идея, откуда взялась, чем может быть полезна;"
" критериев у идеи нет — потому она и идея -->\n")
return ("<!-- задача в одной фразе: что станет наблюдаемо иначе -->\n\n"
f"## {lay.cfg['criteria_heading']}\n\n"
f"<!-- {CRITERIA_MIN}{CRITERIA_MAX} проверяемых утверждений списком,"
f" у каждого назван {lay.cfg['oracle_word']} -->\n\n"
"## Рамки\n\n"
"<!-- одна строка: чего касаться нельзя, что перезапускается,"
" что считается необратимым -->\n")
def cmd_add(lay: Layout, a: argparse.Namespace) -> int:
for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_hook(a.hook),
bad_tags(a.tag), bad_reason(a.reason), 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()
path = lay.items / f"{a.slug}.md"
if path.exists():
raise Usage(f"{path.name} уже существует — дедуп: допиши в него, а не заводи новый")
target = "plan" if kind == GOAL else "backlog"
lines = read_lines(lay.index(target))
if not lines:
raise Usage(f"нет индекса {lay.name(target)} — прогони tasks.py init")
if find_entry_index(lines, a.slug) is not None:
raise Usage(f"строка в {lay.name(target)} для {a.slug} уже есть")
section = a.section or (section_headers(lines)[0][1] if section_headers(lines) else "")
hi, section = find_section(lines, section)
if hi is None:
avail = ", ".join(n for _, n in section_headers(lines))
raise Usage(f"нет секции «{a.section}» в {lay.name(target)} (есть: {avail})")
tags = split_tags(a.tag)
if a.goal:
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):
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):
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
meta = build_meta(section, a.reason or "", a.hook or "", tags)
insert_entry(lines, section, entry_line(lay, title_full, a.slug, a.hook or ""))
plan = Plan()
plan.file(path, f"# {title_full}\n\n{meta}\n\n{body_template(kind, lay)}")
plan.index(lay, target, lines)
plan.commit()
print(f"создано: {lay.cfg['items']}/{a.slug}.md,"
f" строка в {lay.name(target)} (секция «{section}»); допиши тело редактором")
if sslug and f"{SPRINT_TAG}{sslug}" in tags:
print(f" помечено тегом {SPRINT_TAG}{sslug} — урожай идущего спринта")
if not a.hook:
print(f" без хука — задай: tasks.py edit {a.slug} --hook …")
warn_rejected(lay, a.slug, a.title)
return EXIT_OK
def warn_rejected(lay: Layout, slug: str, title: str) -> None:
"""Дедупликация против ушедшего без реализации: та же задача возвращается
через квартал тем же текстом, и кладбище — единственный её след."""
path = lay.index("rejected")
if not path.is_file():
return
words = {w for w in re.findall(r"[^\W\d_]{5,}", title.lower())}
for line in read_lines(path):
if not REJECTED_ENTRY.match(line):
continue
low = line.lower()
if f"`{slug}`" in low or (words and len(words & set(re.findall(r"[^\W\d_]{5,}", low))) >= 2):
print(f" похоже на ушедшее без реализации — покажи пользователю, что изменилось:\n"
f" {line.strip()}")
def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_hook(a.hook),
bad_tags(a.add_tag), bad_tags(a.rm_tag), 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.hook, a.type, a.goal, a.add_tag, a.rm_tag, a.section)):
raise Usage("нечего менять: дай --title, --hook, --type, --goal, --add-tag или --rm-tag")
path = lay.items / f"{a.slug}.md"
if not path.exists():
raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/")
places = locate_all(lay, a.slug)
if not places:
raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix")
task = parse_task(path)
if a.title is not None and not a.title.strip():
raise Usage("пустой заголовок")
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()
old_home, new_home = home_index(task), home_index({"type": kind})
in_sprint = "sprint" in places
# Задача в спринте меняет тип только через выход из набора: [epic] или
# [idea] в наборе — состояние, которое check объявит ошибкой, а молчаливый
# успех оставит набор в нём.
if in_sprint and kind not in TAKEABLE:
raise Usage(f"{a.slug} в спринте, а тип «{kind}» в наборе не живёт:"
f" сперва выведи задачу — tasks.py sprint drop {a.slug} --reason …")
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("#"):
raise Usage(f"{a.slug}.md без заголовка H1 — прогони check")
tags = list(task["tags"])
if a.rm_tag is not None:
drop = set(split_tags(a.rm_tag))
tags = [t for t in tags if t not in drop]
if a.add_tag is not None:
for t in split_tags(a.add_tag):
if t not in tags:
tags.append(t)
if a.goal is not None:
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 нет — заведи её или поправь тег")
hook = task["hook"] if a.hook is None else a.hook
section = task["section"]
if a.section is not None:
if old_home == new_home:
raise Usage("--section у edit — только вместе со сменой типа, меняющей"
f" индекс. Секция внутри индекса — это move (он пишет причину):"
f" tasks.py move {a.slug} --section {a.section} --reason …")
section = a.section.strip().lower()
# Смена типа между целью и задачей — это переезд между индексами, а не
# отказ: задача лежит ровно в одном индексе, неоднозначности нет.
if old_home != new_home:
target_lines = read_lines(lay.index(new_home))
hi, section_name = find_section(target_lines, section)
if hi is None:
avail = ", ".join(n for _, n in section_headers(target_lines))
raise Usage(f"смена типа переносит строку в {lay.name(new_home)},"
f" а секции «{section}» там нет (есть: {avail}) —"
f" задай `--section <из перечисленных>`")
section = section_name.lower()
new_text = meta_updated(path, section=section if section else None,
hook=hook if a.hook is not None else None,
tags=tags if tags != task["tags"] else None)
if new_text is None:
raise Usage(f"{a.slug}.md без мета-строки **Секция:** — прогони check и почини")
tlines = new_text.splitlines()
tlines[0] = f"# {h1}"
new_text = "\n".join(tlines) + "\n"
plan = Plan()
plan.file(path, new_text)
if old_home != new_home:
old_lines, ei = places[old_home]
entry = old_lines.pop(ei)
plan.index(lay, old_home, old_lines)
target_lines = read_lines(lay.index(new_home))
insert_entry(target_lines, section, entry_line(lay, h1, a.slug, hook))
plan.index(lay, new_home, target_lines)
else:
for kind_index, (lines, ei) in places.items():
lines[ei] = entry_line(lay, h1, a.slug, hook)
plan.index(lay, kind_index, lines)
plan.commit()
changed = [n for n, v in (("заголовок", a.title), ("хук", a.hook), ("тип", a.type),
("цель", a.goal), ("теги", a.add_tag or a.rm_tag))
if v is not None]
print(f"{a.slug}: обновлено ({', '.join(changed)})")
if len(places) > 1:
print(f" задача была сразу в нескольких индексах"
f" ({', '.join(lay.name(k) for k in places)}) — поправлены все строки,"
f" но само это состояние ошибочно: прогони check")
if old_home != new_home:
print(f" строка переехала: {lay.name(old_home)}{lay.name(new_home)}"
f" (секция «{section}»)")
if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"] and in_sprint:
print(" задача в спринте, а вопрос открыт: либо остаток есть и вопрос ждёт сессии,"
f" либо задача выходит — `tasks.py sprint drop {a.slug} --reason …`")
return EXIT_OK
def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
for err in (bad_slug(a.slug), bad_reason(a.reason), bad_slug(a.after) if a.after else None):
if err:
raise Usage(err)
path = lay.items / f"{a.slug}.md"
if not path.exists():
raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/")
places = locate_all(lay, a.slug)
if not places:
raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix")
if "sprint" in places:
raise Usage(f"{a.slug} в спринте: секция — свойство беклога."
f" Сперва верни задачу: tasks.py sprint drop {a.slug} --reason …")
if len(places) > 1:
raise Usage(f"{a.slug} сразу в нескольких индексах"
f" ({', '.join(lay.name(k) for k in places)}) — неоднозначно,"
f" разбери сам: tasks.py check")
kind_index, (lines, ei) = next(iter(places.items()))
hi, section = find_section(lines, a.section)
if hi is None:
avail = ", ".join(n for _, n in section_headers(lines))
raise Usage(f"нет секции «{a.section}» в {lay.name(kind_index)} (есть: {avail})")
new_text = meta_updated(path, section=section, reason=a.reason)
if new_text is None:
raise Usage(f"{a.slug}.md без мета-строки **Секция:** — прогони check и почини")
entry = lines.pop(ei)
try:
insert_entry(lines, section, entry, a.after, a.first)
except KeyError:
raise Usage(f"--after {a.after}: такой строки в секции «{section}» нет")
plan = Plan()
plan.file(path, new_text)
plan.index(lay, kind_index, lines)
plan.commit()
print(f"{a.slug}: перенесено в «{section}» ({lay.name(kind_index)})")
return EXIT_OK
def cmd_close(lay: Layout, a: argparse.Namespace) -> int:
for err in (bad_slug(a.slug), bad_reason(a.reason)):
if err:
raise Usage(err)
path = lay.items / f"{a.slug}.md"
if not path.exists():
raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/")
places = locate_all(lay, a.slug)
if not places:
raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix")
task = parse_task(path)
if task["type"] == GOAL:
open_tasks = [n for n, t in tasks_of(lay).items() if t["goal"] == a.slug]
if open_tasks:
raise Usage(f"у цели {a.slug} осталось открытых задач: {len(open_tasks)}"
f" ({', '.join(sorted(t[:-3] for t in open_tasks))})."
f" Цель закрыта, когда не осталось её задач")
plan = Plan()
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['section'] or '—'}.")
rej = lay.index("rejected")
prev = rej.read_text(encoding="utf-8") if rej.exists() else "# Ушедшее без реализации\n"
if not prev.endswith("\n"):
prev += "\n"
plan.file(rej, prev + bullet + "\n")
for kind_index, (lines, ei) in places.items():
lines.pop(ei)
plan.index(lay, kind_index, lines)
plan.delete(path)
plan.commit()
print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}")
if "sprint" in places and a.reason:
print(" задача закрыта прямо из спринта без реализации — назови это в докладе спринта")
if not a.reason:
print(" дорога назад: файл восстанавливается из git —"
f" `tasks.py reopen {a.slug} --reason «приёмка не сошлась: …»`")
return EXIT_OK
def git_deleted_text(path: Path) -> str | None:
"""Текст файла из коммита, в котором его удалили. Возврат закрытой задачи
возможен ровно потому, что удаление зафиксировано историей."""
try:
sha = subprocess.run(["git", "log", "--diff-filter=D", "--format=%H", "-n", "1",
"--", str(path)], capture_output=True, text=True).stdout.strip()
if not sha:
return None
out = subprocess.run(["git", "show", f"{sha}^:{path}"],
capture_output=True, text=True)
return out.stdout if out.returncode == 0 else None
except FileNotFoundError:
return None
def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int:
"""Задача была закрыта, а приёмка не сошлась.
Закрытие удаляет файл, поэтому без возврата приёмщику, нашедшему
расхождение, возвращать нечего. Порядок правильный — `close --implemented`
после вердикта приёмки, — но ошибка порядка обязана иметь дорогу назад.
"""
for err in (bad_slug(a.slug), bad_reason(a.reason)):
if err:
raise Usage(err)
path = lay.items / f"{a.slug}.md"
if path.exists():
raise Usage(f"{a.slug}.md на месте — возвращать нечего;"
f" пропала строка индекса — это `check --fix`")
text = git_deleted_text(path)
if text is None:
raise Usage(f"в истории git нет удаления {path} — восстановить нечем."
f" Заведи заново: tasks.py add --slug {a.slug} --title …")
task_lines = text.splitlines()
title = task_lines[0].removeprefix("#").strip() if task_lines else a.slug
kind = PLAIN_TYPE
if (m := TYPE_PREFIX.match(title)):
kind = m.group(1).strip().lower()
if a.reason:
upd = meta_updated_text(text, reason=a.reason)
if upd is None:
print(" внимание: мета-строки нет, причина возврата не записана в файл")
else:
text = upd
tmp = parse_task_text(text, path)
plan = Plan()
plan.file(path, text)
goal_of_sprint, _ = sprint_goal(lay)
target = home_index({"type": kind})
section = tmp["section"]
if target == "backlog" and goal_of_sprint and tmp["goal"] == goal_of_sprint:
target, section = "sprint", lay.cfg["sprint_section"]
lines = read_lines(lay.index(target))
if find_entry_index(lines, a.slug) is None:
hi, sec = find_section(lines, section)
if hi is None:
avail = ", ".join(n for _, n in section_headers(lines))
raise Usage(f"секции «{section}» нет в {lay.name(target)} (есть: {avail})")
insert_entry(lines, sec, entry_line(lay, title, a.slug, tmp["hook"]))
plan.index(lay, target, lines)
rej = lay.index("rejected")
if rej.is_file():
keep, removed = [], []
for line in read_lines(rej):
if REJECTED_ENTRY.match(line) and f"`{a.slug}`" in line:
removed.append(line)
else:
keep.append(line)
if removed:
plan.file(rej, "\n".join(keep))
else:
removed = []
plan.commit()
print(f"{a.slug}: возвращён в {lay.name(target)} из истории git"
+ (f" (секция «{section}»)" if target != "sprint" else " (набор спринта)"))
for line in removed:
print(f" снята строка {lay.name('rejected')}: {line.strip()}")
print(" сверь тело: оно восстановлено на момент удаления, всё позднейшее"
" живёт только в коммите задачи")
return EXIT_OK
def parse_task_text(text: str, path: Path) -> dict:
"""parse_task для текста, которого ещё нет на диске (возврат из git)."""
tmp = path.with_name(path.name + ".reopen-tmp")
tmp.write_text(text, encoding="utf-8")
try:
return parse_task(tmp)
finally:
tmp.unlink(missing_ok=True)
def meta_updated_text(text: str, reason: str) -> str | None:
lines = text.splitlines()
mi = next((i for i in range(1, len(lines)) if lines[i].strip()), None)
if mi is None or not META_FIELD.match(lines[mi].strip()):
return None
chunks = lines[mi].split("·")
for idx, chunk in enumerate(chunks):
f = META_FIELD.match(chunk.strip())
if f and f.group(1).strip().lower() in ("секция", "section"):
section = f.group(2).partition("—")[0].strip()
chunks[idx] = f"**Секция:** {section}{reason}"
return "\n".join((*lines[:mi], "·".join(chunks), *lines[mi + 1:])) + "\n"
return None
# --- Спринт ---
def cmd_sprint_start(lay: Layout, a: argparse.Namespace) -> int:
if (err := bad_slug(a.goal)):
raise Usage(err)
entries, _ = parse_entries(read_lines(lay.index("sprint")))
if entries:
raise Usage(f{lay.name('sprint')} ещё есть набор ({len(entries)}) —"
f" закрой спринт: tasks.py sprint close")
gpath = lay.items / f"{a.goal}.md"
if not gpath.exists():
raise Usage(f"цели {a.goal}.md нет в {lay.cfg['items']}/")
goal = parse_task(gpath)
if goal["type"] != GOAL:
raise Usage(f"{a.goal} не цель (тип «{goal['type']}») — спринт набирается под [goal]")
date = a.date or datetime.date.today().isoformat()
if not DATE_RE.fullmatch(date):
raise Usage("дата в формате ГГГГ-ММ-ДД")
# Слаг спринта — дата: естественный, монотонный и уже уникальный. Он нужен
# не для красоты: без него тег `sprint:<слаг>` некому проставить, а на нём
# держится правило «первая порция переоценки — урожай прошедшего спринта».
slug = (a.slug or date).lower()
if not re.fullmatch(r"[a-z0-9][a-z0-9.-]*", slug):
raise Usage(f"слаг спринта «{slug}» — латиница, цифры, дефис и точка")
tasks = tasks_of(lay)
if any(f"{SPRINT_TAG}{slug}" in t["tags"] for t in tasks.values()):
print(f" внимание: тег {SPRINT_TAG}{slug} уже стоит на задачах прошлого спринта"
f" — урожаи склеятся; задай другой `--slug`")
lines = sprint_header(lay, a.goal, goal["title"], date, slug) \
+ [f"## {lay.cfg['sprint_section']}", ""]
plan = Plan()
plan.index(lay, "sprint", lines)
plan.commit()
ready = [n[:-3] for n, t in tasks.items()
if t["goal"] == a.goal and t["type"] in TAKEABLE
and not questions_open(lay, t)]
print(f"спринт начат: цель «{a.goal}», {date}, слаг «{slug}»")
print(f" кандидатов под цель без открытых вопросов: {len(ready)}"
+ (f" ({', '.join(sorted(ready))})" if ready else ""))
print(f" заводимое по ходу метится тегом {SPRINT_TAG}{slug} само — это урожай")
print(" набери: tasks.py sprint take <слаг> …; набор показывается человеку до старта")
return EXIT_OK
def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int:
goal_slug, _ = sprint_goal(lay)
if not goal_slug:
raise Usage("спринт не начат: tasks.py sprint start --goal <слаг>")
sprint_lines = read_lines(lay.index("sprint"))
backlog_lines = read_lines(lay.index("backlog"))
hi, section = find_section(sprint_lines, lay.cfg["sprint_section"])
if hi is None:
raise Usage(f{lay.name('sprint')} нет секции «{lay.cfg['sprint_section']}»")
taken, warn = [], []
for slug in a.slugs:
if (err := bad_slug(slug)):
raise Usage(err)
path = lay.items / f"{slug}.md"
if not path.exists():
raise Usage(f"{slug}.md не найден в {lay.cfg['items']}/")
t = parse_task(path)
if t["type"] not in TAKEABLE:
raise Usage(f"{slug}: тип «{t['type']}» в спринт не берётся —"
f" идея идёт на штурм, эпик на декомпозицию, цель не берут вовсе")
if t["goal"] != goal_slug:
raise Usage(f"{slug}: цель «{t['goal'] or '—'}» не цель спринта «{goal_slug}» —"
f" набор служит одной цели, даже если взять удобно")
# Отказ по факту, а не по метке: непустой раздел «Вопросы» блокирует
# взятие независимо от тега. Забывший тег иначе проходил бы, а
# поставивший спотыкался — стимул ровно обратный правилу.
if questions_open(lay, t):
raise Usage(f"{slug}: непустой раздел «{lay.cfg['questions_heading']}» —"
f" вопрос разбирается до взятия, вне очереди порции."
f" Отвечен — запиши ответ в тело и очисти раздел"
f" (тег снимается `edit {slug} --rm-tag {QUESTION_TAG}`)")
if QUESTION_TAG in t["tags"]:
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
ei = find_entry_index(backlog_lines, slug)
if ei is None:
raise Usage(f"{slug}: строки в {lay.name('backlog')} нет"
f" (уже в спринте? прогони check)")
entry = backlog_lines.pop(ei)
insert_entry(sprint_lines, section, entry)
taken.append(slug)
plan = Plan()
plan.index(lay, "backlog", backlog_lines)
plan.index(lay, "sprint", sprint_lines)
plan.commit()
total = len(parse_entries(sprint_lines)[0])
print(f"взято в спринт: {', '.join(taken)}; в наборе {total}")
for w in warn:
print(f" замечание: {w}")
return EXIT_OK
def cmd_sprint_drop(lay: Layout, a: argparse.Namespace) -> int:
if (err := bad_reason(a.reason)):
raise Usage(err)
sprint_lines = read_lines(lay.index("sprint"))
backlog_lines = read_lines(lay.index("backlog"))
plan = Plan()
dropped = []
# Сперва все проверки и весь план правок, потом запись. Иначе отказ на
# втором слаге оставлял первый файл переписанным при нетронутых индексах:
# задача числилась в спринте и одновременно объясняла, почему из него вышла.
for slug in a.slugs:
if (err := bad_slug(slug)):
raise Usage(err)
ei = find_entry_index(sprint_lines, slug)
if ei is None:
raise Usage(f"{slug}: в наборе спринта такой строки нет")
path = lay.items / f"{slug}.md"
if not path.exists():
raise Usage(f"{slug}: строка в {lay.name('sprint')} есть, а файла"
f" {lay.cfg['items']}/{slug}.md нет — прогони check")
t = parse_task(path)
hi, section = find_section(backlog_lines, t["section"])
if hi is None:
avail = ", ".join(n for _, n in section_headers(backlog_lines))
raise Usage(f"{slug}: секция «{t['section'] or '—'}» не найдена"
f" в {lay.name('backlog')} (есть: {avail})")
new_text = meta_updated(path, section=section, reason=a.reason)
if new_text is None:
raise Usage(f"{slug}.md без мета-строки **Секция:** — прогони check и почини")
plan.file(path, new_text)
insert_entry(backlog_lines, section, sprint_lines.pop(ei))
dropped.append(slug)
plan.index(lay, "backlog", backlog_lines)
plan.index(lay, "sprint", sprint_lines)
plan.commit()
print(f"вышло из спринта: {', '.join(dropped)} — причина записана в мета-строку")
print(f" живого предложения оставаться не должно; наработки, которые жалко,"
f" переносятся в тело задачи текстом")
return EXIT_OK
def cmd_sprint_close(lay: Layout, a: argparse.Namespace) -> int:
if (err := bad_reason(a.reason)):
raise Usage(err)
entries, _ = parse_entries(read_lines(lay.index("sprint")))
goal_slug, _ = sprint_goal(lay)
slug = sprint_slug(lay)
if entries and not a.dissolve:
raise Usage(f"в наборе осталось задач: {len(entries)}"
f" ({', '.join(sorted(n[:-3] for n in entries))}). Спринт кончается, когда"
f" каждая либо сделана (close --implemented), либо вышла (sprint drop"
f" --reason …). Роспуск при блокере — sprint close --dissolve --reason …")
if a.dissolve:
if not a.reason:
raise Usage("--dissolve без --reason: роспуск объясняется блокером")
drop = argparse.Namespace(slugs=sorted(n[:-3] for n in entries), reason=a.reason)
if entries and (rc := cmd_sprint_drop(lay, drop)) != EXIT_OK:
return rc
plan = Plan()
plan.file(lay.index("sprint"), empty_sprint(lay))
plan.commit()
print(f"спринт закрыт (цель «{goal_slug or '—'}», слаг «{slug or '—'}»)"
+ (", набор распущен" if a.dissolve and entries else ""))
print(f" история наборов остаётся в git: `git log -p {lay.index('sprint')}`")
if slug:
print(f" урожай спринта — `tasks.py list --tag {SPRINT_TAG}{slug}`:"
f" завести найденное по ходу обязан закрывающий спринт, а не пайплайн")
print(" дальше — сессия: разбор вопросов → разбор спринта → переоценка → новый набор")
return EXIT_OK
# --- check --fix ---
def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
"""Детерминированная починка дрейфа. Чинит только то, где истина
однозначно в файле или где источник ровно один:
- дубли строк на один файл, рассинхрон заголовка, задача не в своей секции;
- отсутствующая строка индекса — восстанавливается **вместе с хуком** из
мета-строки файла;
- хук, оставшийся только в индексе, — переносится в файл (миграция со
старого формата: другого экземпляра нет, неоднозначности тоже);
- строка в чужом индексе (цель в беклоге, задача в плане) — переносится в
домашний. Задача лежит ровно в одном индексе и не в том, выбирать не из
чего: истина в типе, а тип в файле;
- цель, у которой есть задачи, помечается `decomposed`.
Неоднозначное (ссылка на исчезнувший файл, задача сразу в двух индексах,
битые строки) не трогает — это на суд человека, и о нём говорится вслух.
"""
fixed: list[str] = []
ambiguous: list[str] = []
tasks = tasks_of(lay)
idx = {k: read_lines(lay.index(k)) for k in lay.indexes}
files: dict[Path, str] = {}
dirty: set[str] = set()
# 1. Дубли строк на один файл — оставляем первую.
for kind, lines in idx.items():
seen: set[str] = set()
out: list[str] = []
for l in lines:
m = INDEX_ENTRY.match(l)
if m and Path(m.group(2)).name in seen:
fixed.append(f"{lay.name(kind)}: убран дубль строки {Path(m.group(2)).name}")
dirty.add(kind)
continue
if m:
seen.add(Path(m.group(2)).name)
out.append(l)
idx[kind] = out
# 2. Хук: истина в файле. Если в файле его нет, а в индексе есть — это
# старый формат, и единственный экземпляр надо спасти в файл.
for kind, lines in idx.items():
for i, l in enumerate(lines):
m = INDEX_ENTRY.match(l)
if not m:
continue
name = Path(m.group(2)).name
task = tasks.get(name)
if not task:
continue
idx_hook = (m.group(3) or "").strip()
if not task["hook"] and idx_hook:
upd = meta_updated(task["path"], hook=idx_hook)
if upd is None:
ambiguous.append(f"{name}: хук только в {lay.name(kind)},"
f" а в файле нет мета-строки — перенести некуда")
continue
files[task["path"]] = upd
task["hook"] = idx_hook
fixed.append(f"{name}: хук перенесён из {lay.name(kind)} в мета-строку файла")
if m.group(1) != task["title"] or idx_hook != task["hook"]:
lines[i] = entry_line(lay, task["title"], name[:-3], task["hook"])
fixed.append(f"{lay.name(kind)}: строка синхронизирована с файлом: {name}")
dirty.add(kind)
# 3. Нет строки вовсе / строка в чужом индексе / не в своей секции. Задачу
# из спринта не трогаем: «в спринте» — решение набора, а не свойство файла.
for name, task in tasks.items():
where = [k for k in lay.indexes if find_entry_index(idx[k], name[:-3]) is not None]
if len(where) > 1:
ambiguous.append(f"{name}: сразу в {', '.join(lay.name(k) for k in where)}"
f" — какая строка лишняя, решает человек")
continue
home = home_index(task)
if not where:
if not task["section"]:
ambiguous.append(f"{name}: строки нет ни в одном индексе, и в файле"
f" нет секции — восстанавливать не по чему")
continue
hi, section = find_section(idx[home], task["section"])
if hi is None:
ambiguous.append(f"{name}: строки нет, а секции «{task['section']}»"
f" нет в {lay.name(home)} — восстанавливать некуда")
continue
insert_entry(idx[home], section, entry_line(lay, task["title"], name[:-3],
task["hook"]))
fixed.append(f"{lay.name(home)}: восстановлена строка {name}"
+ ("" if task["hook"] else " (в файле нет хука — допиши)"))
dirty.add(home)
continue
kind = where[0]
if kind == "sprint":
continue
if kind != home:
# Задача ровно в одном индексе и не в своём: истина в типе, а тип
# в файле — неоднозначности нет, переносим и говорим об этом.
hi, section = find_section(idx[home], task["section"])
if hi is None:
ambiguous.append(f"{name}: лежит в {lay.name(kind)}, дом — {lay.name(home)},"
f" но секции «{task['section']}» там нет"
f" (есть: {', '.join(n for _, n in section_headers(idx[home]))})")
continue
ei = find_entry_index(idx[kind], name[:-3])
entry = idx[kind].pop(ei)
insert_entry(idx[home], section, entry)
fixed.append(f"{name}: строка перенесена {lay.name(kind)}{lay.name(home)}"
f" (секция «{section}») — по типу «{task['type']}» её место там")
dirty.add(kind)
dirty.add(home)
continue
if not task["section"]:
continue
hi, section = find_section(idx[kind], task["section"])
if hi is None:
continue
ei = find_entry_index(idx[kind], name[:-3])
cur = next((SECTION.match(idx[kind][j]).group(1)
for j in range(ei, -1, -1) if SECTION.match(idx[kind][j])), None)
if cur and cur.lower() != section.lower():
insert_entry(idx[kind], section, idx[kind].pop(ei))
fixed.append(f"{lay.name(kind)}: перенесена в секцию «{section}»: {name}")
dirty.add(kind)
# 4. Цель, у которой есть задачи, разобрана по факту — пометка производна.
for name, task in tasks.items():
if task["type"] != GOAL or DECOMPOSED_TAG in task["tags"]:
continue
if any(t["goal"] == name[:-3] for t in tasks.values()):
upd = meta_updated(task["path"], tags=[*task["tags"], DECOMPOSED_TAG])
if upd is None:
continue
files[task["path"]] = upd
fixed.append(f"{name}: проставлен тег «{DECOMPOSED_TAG}» — у цели есть задачи")
plan = Plan()
for path, text in files.items():
plan.file(path, text)
for kind in dirty:
plan.index(lay, kind, idx[kind])
plan.commit()
return fixed, ambiguous
# --- init ---
def init_files(lay: Layout, sections: list[str], plan_sections: list[str],
cfg: dict) -> dict[Path, str]:
out: dict[Path, str] = {}
if cfg:
out[lay.root / CONFIG_NAME] = json.dumps(cfg, ensure_ascii=False, indent=2) + "\n"
out[lay.index("backlog")] = (
"# Беклог\n\n"
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
"+ строка здесь. Целей тут нет — они в "
f"[{lay.name('plan')}]({lay.name('plan')}): беклог — то, что берут,\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"
f"Оглавление целей. Цель — файл `[goal]` в `{lay.cfg['items']}/`; её задачи\n"
"здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.\n"
f"Первая секция («{plan_sections[0]}») упорядочена, и порядок обосновывается\n"
"прозой; остальные — тематические кусты без порядка.\n\n"
+ "".join(f"## {s}\n\n" for s in plan_sections))
out[lay.index("sprint")] = empty_sprint(lay)
out[lay.index("rejected")] = (
"# Ушедшее без реализации\n\n"
"Задачи, покинувшие беклог **без реализации**, с причиной и датой.\n"
f"Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них\n"
"есть коммит. Это первое место, куда смотрит дедупликация при заведении.\n\n"
"<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->\n")
return out
def uniq_sections(raw: str) -> list[str]:
out, seen = [], set()
for s in (s.strip() for s in raw.split(",")):
if s and s.lower() not in seen:
out.append(s)
seen.add(s.lower())
return out
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),
("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:
raise Usage("пустой список секций")
blockers = [s for s in sections if s.lower() in BLOCKER_SECTIONS]
if blockers:
raise Usage(f"секции «{', '.join(blockers)}» в беклоге не заводим: блокер — это"
f" состояние, а не полка. Он живёт до ответа человека, а следы"
f" остаются вопросами в файлах задач; постоянно пустая секция"
f" со старой семантикой «разбираются пачками» противоречит"
f" правилу «спрашиваем немедленно»")
lay.items.mkdir(parents=True, exist_ok=True)
plan = Plan()
for path, text in init_files(lay, sections, plan_sections, cfg).items():
plan.file(path, text)
plan.commit()
print(f"каталог задач заведён: {root}")
print(f" секции беклога: {', '.join(sections)}; части плана: {', '.join(plan_sections)}")
if cfg:
print(f" имена частей записаны в {root / CONFIG_NAME}")
return EXIT_OK
# --- adopt: прийти в чужой репозиторий и вывести каталог задач из того, что есть ---
INDEX_CANDIDATES = ("README.md", "BACKLOG.md", "index.md", "INDEX.md")
GRAVEYARD_CANDIDATES = ("CLOSED.md", "REJECTED.md", "DONE.md")
OLD_META = re.compile(r"^\*\*(Приоритет|Секция|Priority|Section):\*\*\s*(.*)$")
QUESTION_HEADINGS = ("что решить", "варианты и цена", "открытый вопрос", "вопрос",
"что заблокировано", "рекомендация")
def translit_ish(slug: str) -> bool:
"""Явные признаки транслита — и только они.
Это подсказка, а не приговор: отличить английское слово от транслита машина
не умеет, поэтому scan печатает и общее число слагов, требуя проверить все.
Сам перевод («taj-brejk» → «tie-break») делает агент.
"""
return bool(re.search(r"shch|zh|kh|sch|yu|ya|yj|ij|tsi|nyj|ost|enie", slug))
def scan_old_backlog(src: Path) -> dict:
"""Раскладка av-dev-backlog: индекс README.md, кладбище CLOSED.md, файлы
рядом с индексом, приоритеты секциями."""
index_name = next((n for n in INDEX_CANDIDATES if (src / n).is_file()), None)
graveyard = next((n for n in GRAVEYARD_CANDIDATES if (src / n).is_file()), None)
found: dict = {"kind": "backlog-dir", "source": str(src), "index": index_name,
"graveyard": graveyard, "items": [], "rejected": [], "unclassified": []}
entries, sections = ({}, [])
if index_name:
entries, sections = parse_entries(read_lines(src / index_name))
found["sections"] = sections
seen: set[str] = set()
for path in sorted(src.glob("*.md")):
if path.name in INDEX_CANDIDATES or path.name in GRAVEYARD_CANDIDATES:
continue
seen.add(path.name)
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
title = lines[0].removeprefix("#").strip() if lines and lines[0].startswith("#") else ""
kind = PLAIN_TYPE
bare = title
if (m := TYPE_PREFIX.match(title)):
kind, bare = m.group(1).strip().lower(), m.group(2).strip()
entry = entries.get(path.name, {})
old_section, reason = "", ""
body_start = 1
for i, line in enumerate(lines[1:8], 1):
if (m := OLD_META.match(line.strip())):
old_section, _, reason = (p.strip() for p in m.group(2).partition("—"))
body_start = i + 1
break
body = "\n".join(lines[body_start:]).strip()
headings = [h.lower() for h in re.findall(r"^##\s+(.+?)\s*$", body, flags=re.M)]
qh = next((h for h in headings if h in QUESTION_HEADINGS), "")
found["items"].append({
"old_slug": path.stem, "slug": path.stem, "title": bare, "type": kind,
"old_section": old_section or entry.get("section", ""),
"section": "", "reason": reason,
"hook": entry.get("hook", ""), "goal": "",
"in_index": path.name in entries,
"translit": translit_ish(path.stem),
"questions_heading": qh,
"source": str(path),
})
for name, entry in entries.items():
if name not in seen:
found["unclassified"].append({
"what": f"строка индекса «{entry['title']}» → {name}",
"where": f"{src / index_name}:{entry['line']}",
"why": "файла нет — переносить нечего, текст только в строке"})
if graveyard:
for line in read_lines(src / graveyard):
if line.startswith("- "):
found["rejected"].append(line)
return found
STEP = re.compile(r"^\**\s*(\d+)[.)]\s*\**\s*(.+?)\**\s*$")
def scan_list_file(path: Path) -> dict:
"""TODO.md, «планы» в README, список шагов в плане проекта.
**Нумерованный шаг плана → кандидат в цель линии** (готовая цель: у него уже
есть порядок и обоснование), прочий пункт списка → кандидат в задачу. Ничего
не решает: и то и другое едет в карту предложением, назначает человек.
"""
found: dict = {"kind": "list-file", "source": str(path), "items": [],
"goal_candidates": [], "unclassified": []}
heading = ""
# Пункт, перенесённый на следующие строки, — один пункт: иначе половина
# абзаца уезжает в заголовок задачи обрубком.
bullets: list[list] = [] # [строка, состояние, текст, заголовок]
open_bullet = False
for num, line in enumerate(read_lines(path), 1):
if (m := re.match(r"^#{1,6}\s+(.+?)\s*$", line)):
heading, open_bullet = m.group(1).strip(), False
continue
# Нумерованный пункт номер сохраняет: по нему он и опознаётся шагом.
m = re.match(r"^\s*[-*]\s*(?:\[([ xX~])\]\s*)?(.+?)\s*$", line) \
or re.match(r"^\s*()(\d+[.)]\s+.+?)\s*$", line)
if m:
bullets.append([num, (m.group(1) or " "), m.group(2).strip(), heading])
open_bullet = True
elif not line.strip():
open_bullet = False
elif open_bullet:
bullets[-1][2] += " " + line.strip()
for num, state, text, heading in bullets:
clean = re.sub(r"[`*]", "", text).strip()
if (s := STEP.match(text)):
found["goal_candidates"].append({
"title": re.sub(r"[`*]", "", s.group(2)).strip().rstrip("."),
"from": f"{path}:{num}", "step": int(s.group(1)),
"done": state.lower() == "x", "section": heading})
continue
if len(clean) < 4:
found["unclassified"].append({"what": clean[:60], "where": f"{path}:{num}",
"why": "пункт короче четырёх символов"})
continue
if len(clean) > 200:
found["unclassified"].append({"what": clean[:60] + "…", "where": f"{path}:{num}",
"why": "абзац прозой, а не пункт списка —"
" задача из него не выводится машинально"})
continue
found["items"].append({
"old_slug": "", "slug": "", "title": clean[:120],
"type": PLAIN_TYPE, "old_section": heading, "section": "", "reason": "",
"hook": "", "goal": "", "in_index": False, "translit": False,
"questions_heading": "", "source": f"{path}:{num}", "body": text,
"done": state.lower() == "x",
})
return found
def cmd_adopt_scan(a: argparse.Namespace) -> int:
sources = [Path(s) for s in a.sources]
for s in sources:
if not s.exists():
raise Usage(f"источник не найден: {s}")
scans = []
for s in sources:
scans.append(scan_old_backlog(s) if s.is_dir() else scan_list_file(s))
items, rejected, unclassified, goals = [], [], [], []
for sc in scans:
items += sc["items"]
rejected += sc.get("rejected", [])
unclassified += sc.get("unclassified", [])
goals += [{"slug": "", "title": g["title"],
"section": (a.plan_sections.split(",")[0].strip() or "линия"),
"from": g["from"], "step": g.get("step"), "done": g.get("done"),
"body": f"Выведена из шага «{g['title']}» ({g['from']})."
+ ("\n\nШаг помечен закрытым — цель, скорее всего,"
" заводить не надо." if g.get("done") else "")}
for g in sc.get("goal_candidates", [])]
dup: dict[str, int] = {}
for it in items:
dup[it["slug"] or it["title"]] = dup.get(it["slug"] or it["title"], 0) + 1
for key, n in dup.items():
if n > 1:
unclassified.append({"what": key, "where": "несколько источников",
"why": f"слаг встретился {n} раза — оставь один"})
# Куда переехали сами файлы: индекс, кладбище и каталог записей. Без этого
# ссылки вида `docs/backlog/README.md` из чужих документов останутся битыми.
path_map: list[list[str]] = []
for sc in scans:
if sc["kind"] != "backlog-dir":
continue
src = sc["source"].rstrip("/")
if sc.get("index"):
path_map.append([f"{src}/{sc['index']}", f"{a.target}/{DEFAULTS['backlog']}"])
if sc.get("graveyard"):
path_map.append([f"{src}/{sc['graveyard']}", f"{a.target}/{DEFAULTS['rejected']}"])
path_map.append([f"{src}/", f"{a.target}/{DEFAULTS['items']}/"])
plan = {
"version": 1,
"target": a.target,
"sources": [str(s) for s in sources],
"path_map": path_map,
"sections_backlog": uniq_sections(a.sections),
"sections_plan": uniq_sections(a.plan_sections),
"section_map": {},
"goals": goals,
"items": items,
"rejected": rejected,
"unclassified": unclassified,
}
print(f"адаптация: найдено записей {len(items)},"
f" кандидатов в цели {len(goals)},"
f" строк кладбища {len(rejected)},"
f" не разложилось {len(unclassified)}")
for sc in scans:
if sc["kind"] == "backlog-dir":
print(f" {sc['source']}: раскладка беклога, индекс"
f" {sc['index'] or '—'}, кладбище {sc['graveyard'] or '—'},"
f" секции: {', '.join(sc['sections']) or '—'}")
else:
print(f" {sc['source']}: список — пунктов {len(sc['items'])},"
f" кандидатов в цели {len(sc['goal_candidates'])}")
by_section: dict[str, int] = {}
for it in items:
by_section[it["old_section"] or "—"] = by_section.get(it["old_section"] or "—", 0) + 1
print(" по исходным секциям: "
+ ", ".join(f"{k} {v}" for k, v in sorted(by_section.items())))
have_slug = [it for it in items if it["old_slug"]]
translit = [it["old_slug"] for it in have_slug if it["translit"]]
if have_slug:
print(f" слагов на входе {len(have_slug)}, из них с явными признаками"
f" транслита {len(translit)} — но проверить надо **все**:"
f" английское слово от транслита машина не отличает,"
f" перевод и переименование делает агент")
if translit:
print(f" явные: {', '.join(translit[:6])}{' …' if len(translit) > 6 else ''}")
no_index = [it["old_slug"] for it in items if it["old_slug"] and not it["in_index"]]
if no_index:
print(f" файлы вне индекса: {', '.join(no_index)}")
done = [it["title"] for it in items if it.get("done")]
if done:
print(f" помечено сделанным на входе ({len(done)}) — сделанное не переносим,"
f" убери из карты: {', '.join(t[:40] for t in done[:5])}")
withq = [it["old_slug"] for it in items if it["questions_heading"]]
if withq:
print(f" похоже на открытый вопрос в прозе ({len(withq)}):"
f" {', '.join(withq[:8])}{' …' if len(withq) > 8 else ''}")
if unclassified:
print(" НЕ РАЗЛОЖИЛОСЬ (поимённо):")
for u in unclassified:
print(f" - {u['what']} [{u['where']}]: {u['why']}")
out = Path(a.out)
out.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"\nкарта записана: {out}")
print(" дальше: заполни в карте `slug` (английский), `section`, `goal` у каждой"
" записи и список `goals`, покажи карту человеку и только потом —"
f" `tasks.py adopt apply --plan {out}`")
return EXIT_OK
def requalify_links(text: str, old_dir: Path, new_dir: Path) -> str:
"""Относительные ссылки тела после переезда файла глубже.
`docs/backlog/x.md` знал соседей как `../passport.md`; из
`docs/tasks/items/x.md` тот же файл — уже `../../passport.md`. Молча
съехавшая на уровень ссылка — самый дешёвый способ развалить документацию.
"""
delta = len(new_dir.parts) - len(old_dir.parts)
if delta <= 0:
return text
return re.sub(r"\]\((\.\./)", "](" + "../" * (delta + 1), text)
def rewrite_refs(paths: list[Path], renames: dict[str, str],
path_map: list[tuple[str, str]], dry: bool) -> tuple[int, dict[str, int]]:
"""Перекрёстные ссылки на переименованные слаги — одним проходом.
Переименование, разнесённое по времени, оставляет битые ссылки, которых
никто не проверяет: `[текст](старый.md)`, `` `старый` `` и голое упоминание
в прозе. Считаем и говорим, сколько нашли и где.
"""
per_slug: dict[str, int] = {}
touched = 0
pats = [(re.compile(r"(?<![\w-])" + re.escape(old) + r"(?![\w-])"), old, new)
for old, new in renames.items() if old != new]
for path in paths:
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
original = text
for pat, old, new in pats:
text, n = pat.subn(new, text)
if n:
per_slug[old] = per_slug.get(old, 0) + n
for old_prefix, new_prefix in path_map:
text = text.replace(old_prefix, new_prefix)
if text != original:
touched += 1
if not dry:
write_atomic(path, text)
return touched, per_slug
def cmd_adopt_apply(a: argparse.Namespace) -> int:
plan_path = Path(a.plan)
if not plan_path.is_file():
raise Usage(f"карты нет: {plan_path}")
try:
pl = json.loads(plan_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise Usage(f"{plan_path}: не разбирается как JSON — {e}")
root = Path(pl["target"])
if not dir_within_cwd(root):
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)
known_sections = {s.lower() for s in sections}
known_plan = {s.lower() for s in plan_sections}
# --- проверки: все до первой записи ---
problems: list[str] = []
if lay.index("backlog").exists():
problems.append(f"{lay.index('backlog')} уже есть — адаптация не поверх живого"
f" каталога; выбери пустой target")
slugs: set[str] = set()
for g in pl.get("goals", []):
if not g.get("slug"):
problems.append(f"цель «{g.get('title', '?')}» без слага — заполни карту")
continue
if (e := bad_slug(g["slug"])):
problems.append(f"цель: {e}")
if g["slug"] in slugs:
problems.append(f"слаг «{g['slug']}» встречается дважды")
slugs.add(g["slug"])
if g.get("section", "").lower() not in known_plan:
problems.append(f"цель {g['slug']}: секция «{g.get('section', '')}»"
f" не из плана ({', '.join(plan_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")
if not slug:
problems.append(f"запись «{it.get('title', '?')}» без слага")
continue
if (e := bad_slug(slug)):
problems.append(e)
if slug in slugs:
problems.append(f"слаг «{slug}» встречается дважды")
slugs.add(slug)
if it.get("section", "").lower() not in known_sections:
problems.append(f"{slug}: секция «{it.get('section', '')}» не из беклога"
f" ({', '.join(sections)})")
if it.get("goal") and it["goal"] not in goal_slugs:
problems.append(f"{slug}: цель «{it['goal']}» не заведена в карте")
for e in (bad_hook(it.get("hook")), bad_reason(it.get("reason"))):
if e:
problems.append(f"{slug}: {e}")
if problems:
for p in problems:
print(f"ОШИБКА {p}")
raise Usage(f"карта не готова: {len(problems)} проблем — правь {plan_path}")
# --- план записи ---
wr = Plan()
for path, text in init_files(lay, sections, plan_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()
renames: dict[str, str] = {}
for g in pl.get("goals", []):
title = f"[{GOAL}] {g['title']}"
meta = build_meta(g["section"].lower(), g.get("reason", ""), g.get("hook", ""),
g.get("tags", []))
body = g.get("body", "").strip()
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(),
entry_line(lay, title, g["slug"], g.get("hook", "")))
for it in pl.get("items", []):
slug = it.get("slug") or it["old_slug"]
if it.get("old_slug") and it["old_slug"] != slug:
renames[it["old_slug"]] = slug
kind = (it.get("type") or PLAIN_TYPE).lower()
title = it["title"] if kind == PLAIN_TYPE else f"[{kind}] {it['title']}"
tags = list(it.get("tags", []))
if it.get("goal"):
tags.append(f"{GOAL_TAG}{it['goal']}")
body = it.get("body", "")
if not body and it.get("source") and Path(str(it["source"]).split(":")[0]).is_file():
src = Path(str(it["source"]).split(":")[0])
src_lines = src.read_text(encoding="utf-8").splitlines()
start = 1
for i, line in enumerate(src_lines[1:8], 1):
if OLD_META.match(line.strip()):
start = i + 1
break
body = "\n".join(src_lines[start:]).strip()
body = requalify_links(body, src.parent, lay.items)
qh = it.get("questions_heading", "")
if qh:
body = re.sub(rf"^##\s+{re.escape(qh)}\s*$", f"## {lay.cfg['questions_heading']}",
body, count=1, flags=re.M | re.I)
if QUESTION_TAG not in tags:
tags.append(QUESTION_TAG)
meta = build_meta(it["section"].lower(), it.get("reason", ""), it.get("hook", ""), tags)
wr.file(lay.items / f"{slug}.md", f"# {title}\n\n{meta}\n\n{body}\n")
insert_entry(backlog_lines, it["section"].lower(),
entry_line(lay, title, slug, it.get("hook", "")))
if pl.get("rejected"):
head = init_files(lay, sections, plan_sections, {})[lay.index("rejected")]
body = []
for line in pl["rejected"]:
line = re.sub(r"Был приоритет:", "Была секция:", line)
for old, new in renames.items():
line = re.sub(r"(?<![\w-])" + re.escape(old) + r"(?![\w-])", new, line)
body.append(line)
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))
if a.dry_run:
print(f"пробный прогон: записалось бы файлов {len(wr.writes)},"
f" переименований слагов {len(renames)}")
return EXIT_OK
lay.items.mkdir(parents=True, exist_ok=True)
wr.commit()
# --- перекрёстные ссылки: тем же проходом, иначе они останутся битыми ---
ref_paths: list[Path] = list(lay.items.glob("*.md")) + [lay.index("rejected")]
for r in (a.refs or []):
p = Path(r)
ref_paths += sorted(p.rglob("*.md")) if p.is_dir() else [p]
touched, per_slug = rewrite_refs(ref_paths, renames,
[tuple(pair) for pair in pl.get("path_map", [])], False)
print(f"каталог задач собран: {root}")
print(f" целей {len(pl.get('goals', []))}, задач {len(pl.get('items', []))},"
f" строк кладбища {len(pl.get('rejected', []))}")
print(f" переименовано слагов: {len(renames)};"
f" ссылок поправлено: {sum(per_slug.values())} в {touched} файлах")
for old, n in sorted(per_slug.items(), key=lambda kv: -kv[1])[:10]:
print(f" {old}{renames[old]}: {n}")
if pl.get("unclassified"):
print(" не разложилось (поимённо, переносить руками):")
for u in pl["unclassified"]:
print(f" - {u['what']} [{u['where']}]: {u['why']}")
# --- честно про переходное состояние: считаем по написанным файлам ---
written = tasks_of(lay)
# У идеи цель не обязательна — check это ошибкой не считает, и мы тоже.
no_goal = [n for n, t in written.items() if t["type"] == PLAIN_TYPE and not t["goal"]]
no_crit = [n for n, t in written.items()
if t["type"] == PLAIN_TYPE
and not criteria_stats(lay, t["body"].get(
lay.cfg["criteria_heading"].lower(), ""))[0] >= CRITERIA_MIN]
print("\nпереходное состояние — назови его в докладе целиком:")
print(f" задач без цели: {len(no_goal)} — это ОШИБКИ check"
f" (правится `tasks.py edit <слаг> --goal <цель>`)"
+ (f": {', '.join(sorted(x[:-3] for x in no_goal)[:5])}…" if no_goal else ""))
print(f" задач без {CRITERIA_MIN}+ критериев приёмки: {len(no_crit)} —"
f" check это ошибкой не считает, но `sprint take` их не возьмёт:"
f" собрать спринт сегодня физически нечем")
print(f" закрывается порциями переоценки по 5–8 задач (скилл session, шаг 3):"
f" проставить цели, превратить «готово, когда» в критерии с оракулами,"
f" вынуть вопросы из прозы в раздел. Готовность к первому спринту —"
f" не «check зелёный», а «есть {CRITERIA_MIN}+ критериев хотя бы у набора"
f" под одну цель».")
print(" источники не удалены: сверь глазами и убери сам"
f" ({', '.join(pl.get('sources', []))}) — удалять чужое молча нельзя.")
print(" подписи ссылок машина не трогает: цель ссылки поправлена, а текст"
" вида «[старый путь](новый путь)» правит агент глазами.")
return EXIT_OK
def main() -> int:
ap = argparse.ArgumentParser(prog="tasks.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", help="от самой залежавшейся")
p.add_argument("--section")
p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE))
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("--questions", action="store_true", help="только с открытым вопросом")
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("--type", choices=TYPES)
p.add_argument("--section")
p.add_argument("--goal", help="слаг цели → тег goal:<слаг>")
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("--goal", help="заменить тег goal:<слаг>")
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.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("--first", action="store_true")
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="ушла без реализации → строка в REJECTED")
g.add_argument("--implemented", action="store_true", help="реализована → просто удалить")
p.add_argument("--dir")
p = sub.add_parser("reopen", help="вернуть закрытую задачу (приёмка не сошлась)")
p.add_argument("slug")
p.add_argument("--reason", help="почему возвращена — уедет в мета-строку")
p.add_argument("--dir")
p = sub.add_parser("sprint", help="операции спринта")
ssub = p.add_subparsers(dest="sprint_command", required=True)
s = ssub.add_parser("start", help="начать спринт под названную цель")
s.add_argument("--goal", required=True)
s.add_argument("--date")
s.add_argument("--slug", help="слаг спринта; по умолчанию дата начала")
s.add_argument("--dir")
s = ssub.add_parser("take", help="взять задачи в набор")
s.add_argument("slugs", nargs="+")
s.add_argument("--dir")
s = ssub.add_parser("drop", help="вернуть задачи в беклог")
s.add_argument("slugs", nargs="+")
s.add_argument("--reason", required=True)
s.add_argument("--dir")
s = ssub.add_parser("close", help="закрыть спринт")
s.add_argument("--dissolve", action="store_true", help="распустить набор (блокер)")
s.add_argument("--reason")
s.add_argument("--dir")
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("--items")
p.add_argument("--backlog")
p.add_argument("--plan")
p.add_argument("--sprint")
p.add_argument("--rejected")
p = sub.add_parser("adopt", help="вывести каталог задач из того, что уже есть в репозитории")
asub = p.add_subparsers(dest="adopt_command", required=True)
s = asub.add_parser("scan", help="только карта: что найдено и как разложилось")
s.add_argument("--from", dest="sources", nargs="+", required=True)
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 = asub.add_parser("apply", help="записать каталог по подтверждённой карте")
s.add_argument("--plan", required=True)
s.add_argument("--refs", nargs="*", help="файлы и каталоги, где чинить ссылки на слаги")
s.add_argument("--dry-run", dest="dry_run", action="store_true")
a = ap.parse_args()
if a.command == "init":
return cmd_init(Path(a.dir or "docs/tasks"), a)
if a.command == "adopt":
return cmd_adopt_scan(a) if a.adopt_command == "scan" else cmd_adopt_apply(a)
lay = resolve_layout(a.dir)
if a.command == "sprint":
return {"start": cmd_sprint_start, "take": cmd_sprint_take,
"drop": cmd_sprint_drop, "close": cmd_sprint_close}[a.sprint_command](lay, a)
return {
"check": lambda: check(lay, a.fix),
"list": lambda: list_tasks(lay, a),
"add": lambda: cmd_add(lay, a),
"edit": lambda: cmd_edit(lay, a),
"move": lambda: cmd_move(lay, a),
"close": lambda: cmd_close(lay, a),
"reopen": lambda: cmd_reopen(lay, a),
}[a.command]()
if __name__ == "__main__":
try:
sys.exit(main())
except Usage as e:
print(f"ошибка: {e}", file=sys.stderr)
sys.exit(EXIT_USAGE)
except Env as e:
print(f"окружение: {e}", file=sys.stderr)
sys.exit(EXIT_ENV)
except KeyboardInterrupt:
sys.exit(EXIT_INTERNAL)
except Exception as e: # noqa: BLE001
print(f"внутренний сбой ({type(e).__name__}): {e}", file=sys.stderr)
sys.exit(EXIT_INTERNAL)