Files
dev-skills/av-dev/skills/task-track/scripts/tasks.py
T
av ed83ec7dc0 задачи: починена смена стадии, разобраны находки ревью плагина
Команда stage была дефектна по шести пунктам, и все шесть подтверждены
прогоном: не звала raw_last (переход оставлял каталог красным), не
переписывала шапку беклога (индекс продолжал объявлять прежнюю стадию),
шла в обход write_config, молча пропускала файлы с непересобираемой метой,
ломалась на беклоге без заголовков и схлопывала полки при первом
объявлении стадии.

Объявление и смена разведены: объявление беклога не трогает вовсе, смена
трогает состав секций только по явному --sections, а слить полки скрипт
не берётся ни в одном случае. Абзац шапки размечен парой «стадия», и
расхождение с конфигом стало обычным дрейфом.

Отказ по недостающей строке индекса запирал запись, пережившую упразднение
роадмапа: edit, close и reopen теперь заводят или пропускают строку сами.
Прочее: регистр stage нормализуется при чтении; --fix снимает мёртвые теги
и у неразобранных записей; move отказывает переставлять сырьё; adopt
держит место сырья; docs.py bump двигает одну запись журнала за раз;
tasks.py получил перечень упразднённых адресов, и гейт наконец видит
собственное упразднение ROADMAP.md.

Запись «Версия 3» переписана по прогону на игрушечном проекте: прежний
порядок шагов был неисполним. Закрыты дыры модели стадий (пересмотр плана
стройки стал сценарием, приёмка отвязана от груминга, from-review,
research и adopt получили развилку по стадии, перечень осей пересчитан) и
находки, старшие этой сессии: review-triage получил режим без метки, три
списка проектных копий сведены к дому с проверяемыми копиями, пять
пересказов правил стали помеченными копиями или ссылками, language.md
перестал объявлять юрисдикцию над чужим плагином.
2026-08-13 15:08:29 +03:00

3376 lines
201 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
"""Детерминированный инструмент управления задачами: файлы против индексов.
Приоритет — не поле, а **порядок строк в беклоге**. Индекс один: беклог.
`REJECTED.md` индексом не считается: он не говорит, где запись числится, он
кладбище ушедшего.
Раскладка. Путь каталога — `tasks/` в корне репозитория по умолчанию; другой
называется ключом `[tasks] dir`. Каталог принадлежит этому скиллу, а не канону
документов: учёт работ ведут и в проекте, который к канону не приведён. Имена
частей, **стадия проекта** и **версия раскладки** живут в `.av-dev.toml` в
корне; журнал версий — references/changelog.md скилла canon.
tasks/
items/ задачи файлами, <slug>.md
BACKLOG.md что можно взять. Порядок строк внутри секции значим,
но значит он **разное на разных стадиях** (см. ниже)
REJECTED.md ушедшее БЕЗ реализации, с причиной и датой
Источник истины — файл задачи в items/. Индекс производен: расходятся —
неправ индекс. **«Зачем» живёт в мете файла**, а не только в строке индекса:
иначе восстановление пропавшей строки (`check --fix`) теряло бы его навсегда.
Исключение из производности одно и намеренное: **порядок строк в беклоге** —
приоритет это свойство очереди, а не задачи, и в файле ему места нет.
Рассогласование ловит check.
**Стадия проекта — ось, и она решает, что значит порядок строк.**
build (стройка) — приложение ещё строится. Беклог = план от базы к
деталям, **секция ровно одна**, порядок = зависимость:
раньше нельзя. Список пишется вперёд целиком, и это не
гниение беклога, а замысел. Пустой беклог значит, что
стройка окончена.
support (доработка) — приложение работает, правки точечные. Секции — полки
домена, порядок внутри полки = важность: раньше лучше.
Заводится по одной, по мере появления; пустой беклог —
нормальное состояние.
Стадия объявляется ключом `[tasks] stage` и меняется командой `stage`. Молчание
ответом не считается: без ключа check отказывает, потому что читать порядок
строк не по чему.
Тип — вторая ось записи и **закрытый словарь из четырёх значений**:
feature | fix | chore | research, по-английски, как и прочие токены команд. Дом
типа — **поле меты `Тип` первой строкой**; эмодзи в заголовке H1 производна от
него, её ставит и чинит `check --fix`. Эмодзи нужна там, где принимают решение
«брать или не брать», — в строке индекса, а она копирует H1 дословно.
Прежних осей было две: тип записи (goal | idea | task) и род работы
(`kind:<род>` тегом). Ортогональность была фальшивой — из двенадцати клеток
произведения законны шесть, — а «алгоритм решения задач такого типа» крепится
не к `task`, а к `fix` и `research`. Отдельного типа `idea` тоже не осталось:
он значил не род работы, а **состояние незаполненности**, и это состояние
теперь называется честно — `research` без раздела «Вопрос».
**Тип `goal` и роадмап упразднены.** Цель была зонтиком над параллельными
направлениями — она нужна там, где список работ нельзя выстроить в один
порядок. У проекта, который ведёт один человек, такого не бывает: на стройке
список линеен по зависимости, на доработке правки независимы. Половину своего
вопроса роадмап при этом дублировал беклогом («чего ещё не умеет» = «что
осталось в списке»), а вторую половину («что уже умеет») отвечают спеки и
`git log` индекса.
**Тип определяет схему записи**: какие разделы тела обязательны, какие
допустимы, берётся ли запись в работу. Схема — TYPE_SCHEMA; проза с алгоритмом
работы над каждым типом — `references/task-<тип>.md`.
Использование:
tasks.py init --stage build|support [--dir DIR] [--sections …] [--items …] …
tasks.py stage [build|support] [--sections …] [--dir DIR]
tasks.py check [--dir DIR] [--fix]
tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag a,b]
[--raw] [--questions]
tasks.py add --slug S --title T --type feature|fix|chore|research
[--section S] [--why H] [--reason R] [--tag a,b] [--dir DIR]
tasks.py edit S [--title T] [--why H] [--type T]
[--add-tag a,b] [--rm-tag c,d] [--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 ready S [S …] [--dir DIR]
tasks.py adopt scan --from PATH [PATH …] --stage S [--target DIR] [--out PLAN.json]
tasks.py adopt apply --plan PLAN.json [--refs PATH …] [--dry-run]
Каталог задач: `--dir` (обязан быть внутри рабочего каталога) → `tasks/` вверх
от текущего каталога. Прежние раскладки (`docs/tasks`, `doc/tasks`) читаются,
пока живы непереехавшие проекты; переезд — запись 11 закрытого журнала канона (changelog-before-merge.md).
Коды выхода — общий словарь скриптов av-dev; дом словаря и разбор «дрейф
против окружения» — av-dev/shared/axes.md. Значения — в константах ниже; здесь
код 1 приходит только от check.
Тело задачи (контекст, критерии, вопросы, ссылки) остаётся агенту — add кладёт
заголовок, мета-блок и шаблон-плейсхолдер; агент дописывает редактором.
Границы безопасности: слаг — только латиница kebab-case (traversal невозможен),
--dir обязан быть внутри рабочего каталога, в заголовок, «зачем» и причину не
пролезет перевод строки: поле меты — ровно одна строка.
Все проверки идут до первой записи; запись — одним проходом (см. Plan).
Язык не зашит инструментально: секции сопоставляются с заголовками индексов как
есть, имена служебных файлов и заголовков настраиваются. Текст задач — русский.
"""
import argparse
import datetime
import importlib.util
import json
import re
import subprocess
import sys
from pathlib import Path
from types import ModuleType
def _load_shared() -> ModuleType:
"""Общий читатель `.av-dev.toml` — `shared/config.py` этого же плагина.
Путь считается от файла скрипта: зовут его из репозитория проекта, где
дерева плагина в текущем каталоге нет.
"""
path = Path(__file__).resolve().parents[3] / "shared" / "config.py"
# Проверка именно файлом: `spec_from_file_location` на отсутствующем пути
# возвращает исправный спек, и падает уже `exec_module` — трейсбеком и кодом
# 1, то есть «найден дрейф, чинится». Битая установка дрейфом не является.
spec = importlib.util.spec_from_file_location("avdev_config", path)
if not path.is_file() or spec is None or spec.loader is None:
print(f"ОТКАЗ: не читается {path} — общий читатель настроек;"
f" переустанови плагин av-dev", file=sys.stderr)
sys.exit(3)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
conf = _load_shared()
CONFIG_NAME = conf.CONFIG_NAME # дом настроек и версии: `.av-dev.toml` в корне
# Версия раскладки — **одна на плагин**, и живёт она в `shared/config.py`.
# Своей у каталога задач больше нет: пока плагинов было три и ставились они
# порознь, проект мог иметь учёт работ без канона документов, и общее число
# было бы домом, которого у половины проектов нет. Плагин один — довод ушёл, а
# два числа вместо одного оставляли бы вопрос «по какому журналу повышать».
#
# Переезды каталога, случившиеся до слияния (в корень, отмена спринтов), задним
# числом в журнал не переписаны: они названы прежними журналами, и второй
# перечень тех же шагов разошёлся бы с первым.
LAYOUT_VERSION = conf.VERSION
VERSION_KEY = conf.VERSION_KEY
EXIT_OK = 0
EXIT_DRIFT = 1
EXIT_USAGE = 2
EXIT_ENV = 3
EXIT_INTERNAL = 4
# Ключи `dir` и `stage` в DEFAULTS не входят намеренно: первый говорит, **где**
# каталог, второй — **на какой стадии проект**, и ни один не называет имени
# части. В `Layout` (тот про имена внутри) им делать нечего.
DIR_KEY = "dir"
DEFAULT_DIR = "tasks"
# Стадия проекта — ось, и решает она, что значит порядок строк беклога.
# На стройке порядок это зависимость (раньше нельзя), на доработке — важность
# (раньше лучше). Отсюда и всё остальное: сколько у беклога секций, как его
# пополняют и что означает его опустошение.
STAGE_KEY = "stage"
BUILD, SUPPORT = "build", "support"
STAGES = (BUILD, SUPPORT)
STAGE_RU = {BUILD: "стройка", SUPPORT: "доработка"}
DEFAULTS = {
"items": "items",
"backlog": "BACKLOG.md",
"rejected": "REJECTED.md",
"criteria_heading": "Критерии приёмки",
"surface_heading": "Затрагивает",
"questions_heading": "Вопросы",
"repro_heading": "Воспроизведение",
"question_heading": "Вопрос",
"answer_heading": "Куда ляжет ответ",
"scope_heading": "Рамки",
"oracle_word": "оракул",
}
# Какие ключи конфига — имена файлов и каталогов (их существование сверяется
# с диском первым делом, иначе кривой ключ выглядит как пропавший файл).
PATH_KEYS = ("items", "backlog", "rejected")
# Упразднённые части каталога — с адресом, куда уехало содержимое. Перечень
# читает `scripts/addresses.py`: адрес, названный в чужой прозе, опровергается
# перечнем владельца, а не памятью. Без этой константы упразднение, сделанное
# здесь, не ловилось бы гейтом вовсе — то есть шаг гейта молчал бы ровно про то,
# ради чего заведён.
RETIRED = {
"ROADMAP.md": "→ BACKLOG.md: роадмап упразднён вместе с типом goal",
"SPRINT.md": "→ порядок строк беклога (спринты отменены)",
}
# Умолчания секций по стадиям. На доработке это **полки домена**: смысла они не
# несут, называет их проект. На стройке секция ровно одна — список от базы к
# деталям, — и её имя тоже дело проекта: различать ей нечего, она одна.
DEFAULT_SECTIONS = {BUILD: "План", SUPPORT: "Ядро,Инфра"}
# Мета — список под заголовком, поле на строку. Старая форма (все поля одной
# строкой через `·`) читается по-прежнему: у проектов на диске лежат файлы в
# ней, и `check --fix` переписывает их в новую. Ради этого разделитель `·` и
# оставался зарезервированным — в новой форме он ничего не разделяет.
META_ITEM = re.compile(r"^-\s+\*\*(.+?):\*\*\s*(.*)$")
META_FIELD = re.compile(r"^\*\*(.+?):\*\*\s*(.*)$")
# «Хук» — прежнее имя поля «Зачем». Читается, чтобы файлы проектов переезжали
# сами; пишется всегда новое.
WHY_KEYS = ("зачем", "why", "хук", "hook")
# Ключи, которые скрипт у себя признаёт. Нужны не разбору (там ключи
# перечислены по месту), а поиску поля, отбившегося от блока: сверять с
# закрытым списком — единственный способ не спутать поле меты со строкой тела
# вида `- **Важно:** …`.
TYPE_KEYS = ("тип", "type")
# Поле места называется **«Категория»**: у задачи оно называет полку беклога, на
# которой она лежит. «Секция» — прежнее имя, оставшееся от целей и роадмапа;
# разбор принимает его по-прежнему, чтобы файлы переезжали сами, а `check --fix`
# переименовывает.
PLACE_KEY = "Категория"
PLACE_KEYS = ("категория", "category", "секция", "section")
META_KEYS = {*TYPE_KEYS, *PLACE_KEYS, "теги", "tags", *WHY_KEYS}
def meta_span(lines: list[str]) -> tuple[int, int] | None:
"""Границы мета-блока — `[начало, конец)`. None, если меты нет.
Мета начинается первой непустой строкой после заголовка. Список тянется,
пока строки ему принадлежат; старая форма занимает ровно одну строку.
"""
i = next((i for i in range(1, len(lines)) if lines[i].strip()), None)
if i is None:
return None
if META_ITEM.match(lines[i].strip()):
j = i
while j < len(lines) and META_ITEM.match(lines[j].strip()):
j += 1
return i, j
return (i, i + 1) if META_FIELD.match(lines[i].strip()) else None
def meta_legacy(lines: list[str], span: tuple[int, int]) -> bool:
"""Мета в старой форме — одной строкой. Это дрейф, чинит `check --fix`."""
start, end = span
return end - start == 1 and not META_ITEM.match(lines[start].strip())
def meta_fields(lines: list[str], span: tuple[int, int]) -> list[tuple[str, str]]:
"""Поля мета-блока парами «ключ, значение», в порядке файла."""
start, end = span
chunks = lines[start].split("·") if meta_legacy(lines, span) else lines[start:end]
out = []
for chunk in chunks:
if (f := META_ITEM.match(chunk.strip()) or META_FIELD.match(chunk.strip())):
out.append((f.group(1).strip(), f.group(2).strip()))
return out
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")
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-]+` — .+")
RESEARCH = "research"
# Прежний дом направления. Тип `goal` упразднён вместе с роадмапом; читается
# только затем, чтобы `check` назвал его вслух, а `--fix` снял его следы.
LEGACY_GOAL_TAG = "goal:"
LEGACY_GOAL = "goal"
# Тип — ось записи и **закрытый словарь**. Открытый разъедется на синонимах
# (`bug`, `bugfix`, `fix`, `defect`), и отбор по типу перестанет отвечать на
# свой единственный вопрос. Ни один тип не подходит — это сигнал, что в записи
# их два и её надо разделить.
TYPES = ("feature", "fix", "chore", RESEARCH)
# Эмодзи **производна от типа**, а не второй его дом: её ставит `add` и чинит
# `check --fix`. Живёт в H1 потому, что строка индекса копирует заголовок
# дословно, — так тип виден там, где решают «брать или не брать», и инвариант
# «заголовок в индексе дословно» остаётся нетронутым.
TYPE_EMOJI = {"feature": "✨", "fix": "🐞", "chore": "🧹", RESEARCH: "🔬"}
# Эмодзи упразднённого типа читается по-прежнему — иначе она не **снимается**:
# заголовок разбирается на «значок + текст», и незнакомый значок уезжает в текст,
# а следующая правка типа ставит второй перед первым («✨ 🎯 …»).
EMOJI_TYPE = {v: k for k, v in TYPE_EMOJI.items()} | {"🎯": LEGACY_GOAL}
TAKEABLE = TYPES # берутся в работу все четыре: целей больше нет
# Заголовок в форме действия требуется там, где исход работы — изменение
# системы. У разведки он называет предмет: её исход знание, и заголовок-действие
# обещал бы решённость, которой ещё нет.
ACTION_TYPES = ("feature", "fix", "chore")
QUESTION_TAG = "question"
# Схема тела на тип: какие разделы обязательны, какие ещё допустимы. Значения —
# **ключи конфига**, а не сами заголовки: имена заголовков проект настраивает,
# и схема, хранящая текст, разошлась бы с ними на первой же настройке.
#
# Обязательность проверяется там, где по ней принимают решение, — командой
# `ready` на входе в работу. Раздел не из схемы даёт
# **замечание**, а не ошибку: свой раздел в теле — законная вольность проекта,
# а вот раздел, которого тип не предполагает, чаще всего означает, что тип
# проставлен не тот.
TYPE_SCHEMA = {
"feature": {"required": ("surface_heading", "criteria_heading"),
"allowed": ("scope_heading", "questions_heading")},
"fix": {"required": ("repro_heading", "surface_heading", "criteria_heading"),
"allowed": ("scope_heading", "questions_heading")},
"chore": {"required": ("surface_heading", "criteria_heading"),
"allowed": ("scope_heading", "questions_heading")},
RESEARCH: {"required": ("question_heading", "answer_heading"),
"allowed": ("scope_heading", "questions_heading")},
}
# Прежние дома типа. Больше не пишутся; читаются, чтобы файлы переезжали сами:
# `check --fix` переносит значение в поле «Тип», снимает тег и ставит эмодзи.
LEGACY_KIND_TAG = "kind:"
LEGACY_IDEA = "idea" # тип `[idea]` упразднён: это research без «Вопроса»
LEGACY_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:
"""Поле меты — ровно одна строка: мета читается построчно."""
return None if value is None else bad_line(value, field)
def bad_reason(reason: str | None) -> str | None:
return bad_meta_text(reason, "причина")
def bad_why(why: str | None) -> str | None:
return bad_meta_text(why, "зачем")
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}» — латиница, цифры и разделители -:. (например question)"
return None
def bad_type(rtype: str | None) -> str | None:
"""Тип — закрытый словарь: открытый разъедется на синонимах.
Пять человек заведут `bug`, `bugfix`, `fix`, `defect` и `починка`, и отбор
по типу перестанет отвечать на свой единственный вопрос.
"""
if rtype is None:
return None
if rtype.strip().lower() not in TYPES:
return (f"тип «{rtype}» не из словаря: {', '.join(TYPES)}."
f" Не подходит ни один — это сигнал, что задача не одна")
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")
tmp.replace(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(spaced_sections(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:
tmp.replace(path)
for path in self.deletes:
path.unlink(missing_ok=True)
class Layout:
"""Каталог задач и имена его частей. Всё настраивается: у соседнего проекта
может быть другой подкаталог и другие имена индексов, а семантика та же."""
def __init__(self, root: Path, cfg: dict, project: Path | None = None,
full: dict | None = None):
self.root = root
# Корень проекта — там, где лежит `.av-dev.toml`. Он нужен отдельно от
# каталога задач: версия объявлена в корне, а имена частей — внутри.
self.project = project or root
self.full = full or {}
self.cfg = {**DEFAULTS,
**{k: v for k, v in cfg.items() if k not in (DIR_KEY, STAGE_KEY)}}
self.items = root / self.cfg["items"]
# Стадия — не имя части, поэтому и не в `cfg`. Пустая строка значит «не
# объявлена», и это отдельное состояние: без неё непонятно, что значит
# порядок строк, и `check` об этом говорит.
#
# Приводится к нижнему регистру ровно потому, что к нему же приводит
# валидация: `stage = "Build"` проходил её и не совпадал ни с одним
# значением здесь, так что каждое ветвление молча уходило в ветку
# «стадии нет» — при зелёном конфиге и объявленной стадии.
self.stage = str(cfg.get(STAGE_KEY, "")).strip().lower()
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",)
def load_config(project: Path) -> dict:
"""Весь `.av-dev.toml` проекта. Секция задач берётся из него отдельно.
Дом настроек — **корень репозитория**, а не каталог задач: файл держит
версию раскладки, которая одна на плагин, и ключ `[tasks] dir`, который
говорит, где каталог лежит. Настройка внутри настраиваемого каталога не
смогла бы сказать, где он.
"""
try:
data = conf.read(project)
except conf.ConfigError as e:
raise Env(str(e)) from e
_validate_config(conf.section(data, "tasks"), project / CONFIG_NAME)
return data
def tasks_section(full: dict) -> dict:
return conf.section(full, "tasks")
def _validate_config(data: dict, path: Path) -> dict:
unknown = set(data) - set(DEFAULTS) - {DIR_KEY, STAGE_KEY}
# Прежние дома, названные поимённо. Без этих веток проект со старым конфигом
# получал бы «неизвестный ключ» и искал опечатку там, где на самом деле
# упразднённая часть раскладки.
gone = sorted({"plan", "roadmap", "completion_heading"} & unknown)
if gone:
raise Env(f"{path}: ключ «{gone[0]}» — роадмап и тип goal упразднены:"
f" индекс остался один, беклог, а «Завершение» было разделом"
f" цели. Повысь проект скиллом av-dev:canon (upgrade), а не"
f" правь ключ в одиночку: вместе с файлом разбираются его"
f" записи и ссылки на них")
if unknown:
known = sorted({*DEFAULTS, DIR_KEY, STAGE_KEY})
raise Env(f"{path}: неизвестные ключи в секции [tasks]:"
f" {', '.join(sorted(unknown))} (известны: {', '.join(known)})")
# Версия раскладки лежит ключом верхнего уровня и проверяется общим
# читателем: здесь судится только секция задач, и все её ключи — строки.
for key, value in data.items():
if not isinstance(value, str) or not value.strip():
raise Env(f"{path}: ключ «{key}» — ожидалась непустая строка")
# Стадия — закрытый словарь: по ней ветвится смысл порядка строк, и
# значение вне словаря оставило бы беклог без прочтения вовсе.
if key == STAGE_KEY and value.strip().lower() not in STAGES:
raise Env(f"{path}: ключ «{STAGE_KEY}» = «{value}» — не из словаря"
f" ({', '.join(STAGES)}). Стадия решает, что значит"
f" порядок строк беклога: зависимость или важность")
if key in PATH_KEYS and (value.startswith("/") or ".." in Path(value).parts):
raise Env(f"{path}: ключ «{key}» = «{value}» — только имя внутри каталога задач")
# `dir` судится строже прочих: он указывает каталог, а не имя внутри
# него, и без этой проверки «../соседний» уводит запись за пределы
# репозитория молча — с зелёным кодом и путём, который в докладе
# выглядит своим.
if key == DIR_KEY and (Path(value).is_absolute() or ".." in Path(value).parts):
raise Env(f"{path}: ключ «{DIR_KEY}» = «{value}» — только путь внутри"
f" репозитория, без «..» и без корня")
return data
def config_home(lay: Layout) -> Path | None:
"""Откуда настройки читаются на самом деле — и куда, значит, слать чинить.
Дом один — `.av-dev.toml` в корне проекта; None значит «файла нет, работаем
на умолчаниях». Без этой функции сообщения об ошибке звали бы править файл,
которого нет.
"""
path = lay.project / CONFIG_NAME
return path if path.is_file() else None
def config_problems(lay: Layout) -> list[str]:
"""Каждый путь из конфига сверяется с диском ДО любых выводов о задачах.
Иначе неверный ключ (`items: "tasks"` при каталоге `items/`) заставляет
check обвинять невиновных: «ссылка на несуществующий файл», хотя файл на
месте, а мимо смотрит конфиг.
"""
where = str(config_home(lay) or "умолчания (конфига нет)")
out = []
if not lay.items.is_dir():
out.append(f"{where}: items = «{lay.cfg['items']}» → {lay.items} — каталога нет")
for kind in ("backlog", "rejected"):
p = lay.index(kind)
if not p.is_file():
out.append(f"{where}: {kind} = «{lay.cfg[kind]}» → {p} — файла нет")
return out
def version_problems(lay: Layout) -> list[str]:
"""Версия формата задач: объявлена ли и та ли, которую знает скрипт.
Отвечает на один вопрос — «по какой записи журнала повышать каталог», — и
ни на какой другой. Что запись оформлена по правилам своей версии, отсюда не
следует: число двигает тот, кто прошёл шаги, и соврать им так же легко, как
любой другой строкой. Цена вранья при этом низкая, а польза от вопроса есть
ровно там, где формат поменялся, а каталог остался прежним.
`check --fix` этого не чинит намеренно: приписать недостающее число значило
бы объявить каталог приведённым к формату, шагов которого никто не делал.
Заводит число `init`, двигает — операция `upgrade` скилла.
"""
path = lay.project / CONFIG_NAME
legacy = conf.legacy_files(lay.project, lay.root)
out = []
# Прежние файлы называются всегда, а не только когда нового нет: половина
# переезда — заведён новый, старые остались — иначе проходит молча, и второй
# дом для той же версии живёт дальше.
if legacy and path.is_file():
out.append(f"прежняя раскладка не убрана: {', '.join(legacy)} рядом с"
f" {CONFIG_NAME}. Эти файлы не читаются, а версия в них своя —"
f" удали их: переезд не закончен (журнал, версия 1, шаг 3)")
if not path.is_file():
if legacy:
return [f"нет {path}, а прежняя раскладка на месте"
f" ({', '.join(legacy)}): перенеси настройки и удали старые"
f" файлы операцией upgrade скилла av-dev:canon"]
return [f"нет {path} — версия раскладки не объявлена."
f" Заведи файл с «{VERSION_KEY} = {LAYOUT_VERSION}» (журнал"
f" версий — references/changelog.md скилла av-dev:canon)"]
# Что число целое, уже проверил общий читатель — иначе сюда не дошли бы
# вовсе (код 3). Здесь `isinstance` значит ровно «ключ есть».
got = conf.version(lay.full)
if got is None:
out.append(f"{path}: нет ключа «{VERSION_KEY}» — версия раскладки не"
f" объявлена, текущая {LAYOUT_VERSION}")
elif got < LAYOUT_VERSION:
out.append(f"проект приведён к раскладке версии {got}, текущая —"
f" {LAYOUT_VERSION}: нужно повышение по журналу"
f" (скилл av-dev:canon, операция upgrade)")
elif got > LAYOUT_VERSION:
out.append(f"проект приведён к раскладке версии {got}, а скрипт знает"
f" {LAYOUT_VERSION}: устарел плагин, обнови маркетплейс")
return out
def stage_problems(lay: Layout) -> list[str]:
"""Стадия объявлена или нет. Молчание ответом не считается.
Без стадии порядок строк беклога нечем прочитать: на стройке он зависимость,
на доработке важность, и переставить строку значит в первом случае сломать
план, а во втором — принять решение о важности. `check --fix` этого не
чинит: какая стадия у проекта, знает человек, а подставленное умолчание
соврало бы ровно там, где по нему принимают решение.
"""
if lay.stage in STAGES:
return []
where = config_home(lay) or lay.project / CONFIG_NAME
return [f"{where}: стадия проекта не объявлена — «[tasks] {STAGE_KEY} ="
f" \"{BUILD}\"» (стройка: один список от базы к деталям, порядок это"
f" зависимость) или «\"{SUPPORT}\"» (доработка: полки домена, порядок"
f" это важность). Назови её: `tasks.py stage {BUILD}|{SUPPORT}`"]
def looks_like_tasks(p: Path, names: dict | None = None) -> bool:
"""Каталог задач узнаётся индексом, а не служебным файлом.
Служебный файл теперь лежит в корне проекта и о каталоге говорит ключом
`[tasks] dir`; узнавать каталог по нему значило бы объявить его задачами
ровно там, куда указывает ключ, — даже если по этому пути пусто.
Имя индекса берётся из настроек: проект вправе назвать его по-своему, и
поиск по умолчанию не нашёл бы переименованного каталога вовсе.
"""
name = (names or {}).get("backlog") or DEFAULTS["backlog"]
return (p / name).is_file()
def resolve_layout(explicit: str | None) -> Layout:
"""Каталог задач для команд, кроме init.
Цепочка разрешения: явный `--dir` (обязан быть внутри рабочего каталога) →
ключ `[tasks] dir` из `.av-dev.toml` в корне → умолчание `tasks/` вверх от
текущего каталога. Указатель в `CLAUDE.md` проекта — звено между первым и
вторым, но читает его агент и передаёт сюда `--dir`: скрипт не разбирает
чужую документацию.
"""
here = Path.cwd().resolve()
project = conf.find_root(here)
full = load_config(project) if project else {}
names = tasks_section(full)
if explicit:
root = Path(explicit)
if not dir_within_cwd(root):
raise Env(f"--dir вне рабочего каталога: {explicit}")
if not looks_like_tasks(root, names):
raise Env(f"задач нет в «{explicit}»;"
f" новый проект — tasks.py init --dir {explicit}")
return Layout(root, names, project or root.resolve(), full)
if DIR_KEY in names:
# Ключ назван — значит ответ на «где каталог» уже дан. Не нашли по нему
# — это отказ, а не повод искать дальше: молчаливый уход на умолчание
# означал бы работу в другом каталоге, о котором никто не просил.
candidate = (project or here) / names[DIR_KEY]
if not looks_like_tasks(candidate, names):
raise Env(f"каталог задач не найден по ключу [tasks] {DIR_KEY} ="
f" «{names[DIR_KEY]}» → {candidate}: индекса"
f" {names.get('backlog') or DEFAULTS['backlog']} там нет."
f" Поправь ключ в {CONFIG_NAME} или заведи каталог")
return Layout(relative_if_inside(candidate, here), names, project, full)
if project:
candidate = project / DEFAULT_DIR
if looks_like_tasks(candidate, names):
return Layout(relative_if_inside(candidate, here), names, project, full)
# Проект без `.av-dev.toml` — учёт работ ведут и до того, как канон заведён.
# Тогда каталог ищется умолчанием вверх, а версия объявится на `adopt`.
for base in (here, *here.parents):
for candidate in (base, base / DEFAULT_DIR, base / "docs/tasks", base / "doc/tasks"):
if looks_like_tasks(candidate, names):
return Layout(relative_if_inside(candidate, here), names,
project or base, full)
if (base / ".git").exists():
break # выше корня репозитория не ищем
raise Env(f"каталог задач не найден: ни --dir, ни ключ [tasks] {DIR_KEY} в"
f" {CONFIG_NAME}, ни {DEFAULT_DIR}/ вверх от {here}."
f" Новый проект — tasks.py init --dir {DEFAULT_DIR}")
def relative_if_inside(path: Path, here: Path) -> Path:
"""Путь покороче для сообщений, если каталог лежит под текущим."""
try:
rel = path.relative_to(here)
except ValueError:
return path
return path if str(rel) == "." else rel
# --- Чтение индексов ---
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, why = m.group(1), m.group(2), (m.group(3) or "").strip()
entries[Path(target).name] = {
"title": title, "section": section, "why": why,
"line": num, "target": target}
return entries, sections
INFINITIVE = re.compile(r"(?:ть|ти|чь)(?:ся)?$")
def action_title(title: str) -> bool:
"""Заголовок задачи в форме действия: первое слово — глагол в неопределённой
форме, перед ним допускается «не».
Эвристика, и намеренно грубая: русская морфология без словаря не разбирается,
а «Часть данных теряется» от «Печатать поле» отличается ровно окончанием
первого слова. Поэтому результат идёт **счётчиком в здоровье**, а не
замечанием: ошибиться на одном заголовке дешевле, чем не заметить двадцати.
"""
words = re.findall(r"[^\W\d_]+", title)
if not words:
return False
first = words[0].lower()
if first in ("не", "не-") and len(words) > 1:
first = words[1].lower()
return bool(INFINITIVE.search(first))
def spaced_sections(lines: list[str]) -> list[str]:
"""Отбивка вокруг заголовка секции: пустая строка перед ним и после него.
Живёт на записи, а не на вставке: через `Plan.index` проходит **каждая**
запись индекса, и чинить отбивку в каждом месте вставки значило бы
полагаться на то, что ни одно из них не забыли. Перед заголовком — не
педантизм: перестановка секций двигает целые блоки, и два заголовка легко
оказываются вплотную друг к другу.
Заодно схлопывает подряд идущие пустые строки: удаление строки индекса
оставляет после себя две, и без этого шага пустоты копятся."""
out: list[str] = []
for i, line in enumerate(lines):
if SECTION.match(line):
if out and out[-1].strip():
out.append("")
out.append(line)
if i + 1 < len(lines) and lines[i + 1].strip():
out.append("")
continue
if not line.strip() and out and not out[-1].strip():
continue
out.append(line)
return out
def raw_last(lines: list[str], raw: set[str]) -> list[str]:
"""Строки индекса, у которых сырьё снесено в конец своей секции.
Сырьё (`research` без раздела «Вопрос») в работу не берётся, и стоя между
берущимися оно каждый раз требует открыть файл, чтобы это понять. Порядок
строк в беклоге — приоритет, и назначает его человек; место сырья —
единственное исключение, и оно **производно от типа**, а не назначено, —
потому его и можно проверять машиной.
Переставляются только сами строки-пункты, по своим же позициям: проза
внутри секции, отбивка и заголовки остаются на месте.
"""
out = list(lines)
heads = [i for i, line in enumerate(lines) if SECTION.match(line)]
for k, start in enumerate(heads):
end = heads[k + 1] if k + 1 < len(heads) else len(lines)
pos = [i for i in range(start + 1, end) if INDEX_ENTRY.match(lines[i])]
if not pos:
continue
def is_raw(line: str) -> bool:
m = INDEX_ENTRY.match(line)
return m is not None and Path(m.group(2)).name in raw
vals = [lines[i] for i in pos]
for i, v in zip(pos, [v for v in vals if not is_raw(v)]
+ [v for v in vals if is_raw(v)], strict=True):
out[i] = v
return out
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 = Path(m.group(2)).name
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
# Оформление сверяется **самим нормализатором**, а не своим набором условий:
# два описания одного правила разъедутся, и `check` начнёт молчать о том,
# что `--fix` правит (или наоборот).
if spaced_sections(lines) != lines:
errors.append(f"{label}: оформление секций — заголовок отбивается пустой"
f" строкой с обеих сторон, подряд идущих пустых строк не"
f" бывает; починит `check --fix`")
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 title_parts(title: str) -> tuple[str, str]:
"""(тип, выведенный из заголовка; заголовок без эмодзи и префикса).
Читаются обе формы: текущая (эмодзи) и прежняя (`[goal]`/`[idea]`). Тип из
заголовка — **запасной источник**: дом типа поле меты, а эмодзи от него
производна. Нужен он там, где меты ещё нет: у файлов, не переехавших на
поле, и у текста, восстановленного из git.
"""
bare = title.strip()
if (m := TYPE_PREFIX.match(bare)):
return m.group(1).strip().lower(), m.group(2).strip()
head = bare.split(maxsplit=1)
if head and head[0] in EMOJI_TYPE:
return EMOJI_TYPE[head[0]], (head[1].strip() if len(head) > 1 else "")
return "", bare
def h1_of(rtype: str, bare: str) -> str:
"""Заголовок H1 из типа и чистого текста: эмодзи производна от типа."""
emoji = TYPE_EMOJI.get(rtype)
return f"{emoji} {bare}" if emoji else bare
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 ""
head_type, bare = title_parts(title)
# Мета — блок под заголовком (task-format.md). Порядок полей свободный:
# поле распознаётся, где бы оно ни стояло. Пишется тип первым.
section, section_raw, place_key = "", "", ""
meta_type, reason, why, tags, legacy = "", "", "", [], False
if (span := meta_span(lines)):
legacy = meta_legacy(lines, span)
for key, value in meta_fields(lines, span):
key = key.lower()
if key in TYPE_KEYS:
meta_type = value.strip().lower()
elif key in PLACE_KEYS:
section, _, reason = (p.strip() for p in value.partition("—"))
section = section.rstrip(".,")
section_raw, section = section, section.lower()
place_key = key
elif key in WHY_KEYS:
why = value
elif key in ("теги", "tags"):
tags = [t.strip().lower() for t in value.split(",") if t.strip()]
legacy_goal = next((t for t in tags if t.startswith(LEGACY_GOAL_TAG)), "")
legacy_kind = next((t[len(LEGACY_KIND_TAG):] for t in tags
if t.startswith(LEGACY_KIND_TAG)), "")
# Дом типа — поле меты. Прежние дома читаются по убыванию определённости:
# тег рода работы называл его прямо, заголовок — только у цели и идеи.
rtype = meta_type or legacy_kind or head_type
if rtype == LEGACY_IDEA:
rtype = RESEARCH
return {"title": title, "bare": bare, "type": rtype, "meta_type": meta_type,
"head_type": head_type, "legacy_kind": legacy_kind,
"section": section, "section_raw": section_raw, "place_key": place_key,
"reason": reason, "why": why, "tags": tags, "legacy_goal": legacy_goal,
"path": path, "stray_meta": stray_meta(lines, span),
"legacy_meta": legacy, "text": text, "body": body_sections(text)}
def stray_meta(lines: list[str], span: tuple[int, int] | None) -> list[str]:
"""Поля меты, оставшиеся за пределами блока. Так выглядит мета, разорванная
пустой строкой: разбор дочитывает блок до разрыва, а всё, что ниже,
становится телом — и поля теряются молча. Ловить обязательно: молчаливая
потеря «зачем» или рода работы неотличима от того, что их не задавали, а
следующий `edit` допишет второе такое же поле в мету."""
if span is None:
return []
out = []
for j in range(span[1], len(lines)):
if (m := META_ITEM.match(lines[j].strip())) and m.group(1).strip().lower() in META_KEYS:
out.append(m.group(1).strip())
return out
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 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(Path(line).name, 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 surface_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]:
"""Отказы и замечания по разделу «Затрагивает». Общее для check и take.
Раздел называет **границы**, которых изменение касается: эндпоинт, команду,
таблицу и миграцию, формат на диске, публичный тип пакета. Без него задача
оценивается по объёму текста, а не по объёму поверхности, — и оценка
систематически занижена ровно там, где текст короткий, а границ много.
Механизируется только наличие непустого раздела. Полнота перечня машине не
видна: границу, которую забыли назвать, от отсутствующей она не отличает.
"""
name = task["path"].name
if not task["body"].get(lay.cfg["surface_heading"].lower()):
return ([f"{name}: нет раздела «{lay.cfg['surface_heading']}»"
f" — оценивать будет не по чему: границы (эндпоинт, таблица и"
f" миграция, формат на диске, публичный тип) не названы"], [])
return [], []
# --- Схема тела: тип решает, каких разделов запись обязана иметь ---
# Зачем нужен раздел — по ключу конфига. Текст идёт в отказ: «нет раздела X»
# без причины читается как придирка формы, а причина у каждого своя и
# проектная.
SCHEMA_WHY = {
"repro_heading": "расхождение, которое не воспроизводится, — это research,"
" а не fix: чинить нечего, пока непонятно, что ломается",
"question_heading": "без вопроса это не разведка, а сырьё — в работу не берётся",
"answer_heading": "приёмка разведки — записанный ответ, и место ему"
" (docs/research/, ADR, тело задачи) называется заранее,"
" иначе ответ останется в переписке",
}
def raw_research(lay: Layout, task: dict) -> bool:
"""Сырьё: разведка, у которой ещё нет вопроса.
Прежде это был отдельный тип `[idea]`. Отдельным типом «ещё не описано»
быть не может — это **состояние заполненности**, и различает его раздел, а
не словарь. Отсюда и место сырья: конец секции, чтобы оно не стояло между
тем, что берут.
"""
return (task["type"] == RESEARCH
and not task["body"].get(lay.cfg["question_heading"].lower()))
def schema_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]:
"""Отказы и замечания по схеме тела: разделы, которых требует тип.
Обязательный раздел даёт отказ, лишний — замечание. Разница намеренная:
свой раздел в теле законная вольность проекта, а раздел, которого тип не
предполагает («Воспроизведение» у chore), чаще всего означает, что тип
проставлен не тот, — и об этом стоит сказать, не запрещая.
"""
name, rtype = task["path"].name, task["type"]
schema = TYPE_SCHEMA.get(rtype)
if schema is None:
return ([f"{name}: тип «{rtype or '—'}» вне словаря"
f" ({', '.join(TYPES)}) — какие разделы обязательны, неизвестно"], [])
errors: list[str] = []
notes: list[str] = []
for key in schema["required"]:
if key == "criteria_heading":
e, n = criteria_verdict(lay, task)
elif key == "surface_heading":
e, n = surface_verdict(lay, task)
else:
heading = lay.cfg[key]
e = ([] if task["body"].get(heading.lower())
else [f"{name}: нет раздела «{heading}» — {SCHEMA_WHY[key]}"])
n = []
errors += e
notes += n
known = {lay.cfg[k].lower() for k in (*schema["required"], *schema["allowed"])}
extra = sorted(h for h, v in task["body"].items() if h not in known and v)
if extra:
notes.append(f"{name}: разделы не из схемы типа «{rtype}»:"
f" {', '.join(extra)} — либо тип проставлен не тот,"
f" либо это осознанный раздел проекта")
return errors, notes
def questions_open(lay: Layout, task: dict) -> bool:
"""Открытый вопрос — это **непустой раздел**, а не тег.
Тег производен и забывается; отказ по тегу наказывал бы аккуратного и
пропускал забывчивого — стимул ровно обратный записанному правилу.
"""
return bool(task["body"].get(lay.cfg["questions_heading"].lower()))
# --- 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" (правь {config_home(lay) or lay.project / CONFIG_NAME}"
f" или переименуй файлы)")
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()
lines = read_lines(lay.index("backlog"))
entries, sections = parse_entries(lines)
tasks = tasks_of(lay)
# Версия формата и стадия идут первыми строками расхождений: остальные
# находки читаются иначе, когда каталог отстал от формата или когда неясно,
# что значит порядок строк, — часть из них тогда не дрейф, а непройденный
# шаг журнала.
errors: list[str] = version_problems(lay) + stage_problems(lay)
notes: list[str] = []
label = lay.name("backlog")
known = {s.lower() for s in sections}
raw_names = {n for n, t in tasks.items() if raw_research(lay, t)}
errors += sections_verdict(lay, sections, label)
errors += stage_block_verdict(lay, lines, label)
for s in sections:
if s.lower() in BLOCKER_SECTIONS:
notes.append(f"{label}: секции «{s}» быть не должно —"
f" блокер это состояние, а не полка: он живёт до ответа"
f" человека, а его следы — вопросами в файлах задач")
for name, task in tasks.items():
entry = entries.get(name)
if not SLUG.fullmatch(name):
errors.append(f"{name}: слаг не kebab-case латиницей")
if not task["title"]:
errors.append(f"{name}: нет заголовка H1")
# 0. Тип — единственная ось, и от него зависит всё остальное: схема
# тела, дом строки, имя поля меты, право на взятие в работу.
# Пропуск — замечание: записи, заведённые до появления типа,
# законны, и переоформлять беклог «заодно» здесь не просят.
# Обязательным тип становится там, где по нему принимают решение.
if not task["type"]:
notes.append(f"{name}: тип не назван — `tasks.py edit {name[:-3]}"
f" --type {'|'.join(TYPES)}`; в работу без него не возьмут")
elif task["type"] == LEGACY_GOAL:
errors.append(f"{name}: тип «{LEGACY_GOAL}» упразднён вместе с"
f" роадмапом — цель была зонтиком над параллельными"
f" направлениями, а список работ проекта линеен."
f" Разбери запись сам: её задачи живут дальше, сама она"
f" либо становится одной из них, либо уходит"
f" (`close {name[:-3]} --reason …`)")
elif task["type"] not in TYPES:
errors.append(f"{name}: тип «{task['type']}» вне словаря"
f" ({', '.join(TYPES)}) — словарь закрыт, иначе отбор"
f" по типу разъедется на синонимах")
elif not task["meta_type"]:
was = (f"тегом {LEGACY_KIND_TAG}{task['legacy_kind']}"
if task["legacy_kind"] else "префиксом заголовка")
errors.append(f"{name}: тип задан прежним домом ({was}) — дом типа"
f" поле **Тип:** первой строкой меты; перенесёт"
f" `check --fix`")
elif task["legacy_kind"]:
errors.append(f"{name}: тег {LEGACY_KIND_TAG}{task['legacy_kind']} рядом с"
f" полем **Тип:** — род работы стал типом,"
f" тег снимает `check --fix`")
# 0а. Эмодзи производна от типа и живёт в H1: строка индекса копирует
# заголовок дословно, и тип виден там, где решают «брать или нет».
if task["type"] in TYPES and task["title"]:
want_h1 = h1_of(task["type"], task["bare"])
if task["title"] != want_h1:
errors.append(f"{name}: заголовок не несёт эмодзи типа"
f" «{task['type']}» — надо «{want_h1}»;"
f" поставит `check --fix`")
# 1. У каждого файла есть строка индекса.
if entry is None:
errors.append(f"{name}: нет строки в {label} — восстановит `check --fix`,"
f" если секция файла есть в индексе")
# 2. Секция файла — истина, секция индекса производна.
if task["legacy_meta"]:
errors.append(f"{name}: мета одной строкой — старая форма;"
f" `check --fix` перепишет её списком")
if task["stray_meta"]:
errors.append(f"{name}: поле меты в теле"
f" ({', '.join(task['stray_meta'])}) — мета разорвана"
f" пустой строкой, и всё, что ниже разрыва, потеряно."
f" Убери пустую строку внутри блока; `--fix` этого не"
f" делает: какое из двух значений верное, знает человек")
if task["place_key"] and task["place_key"] != PLACE_KEY.lower():
errors.append(f"{name}: поле меты названо «{task['place_key']}», а зовётся"
f" оно «{PLACE_KEY}»: это полка беклога, на которой задача"
f" лежит. «Секция» была именем той же строки у цели —"
f" целей нет. Переименует `check --fix`")
if not task["section"]:
errors.append(f"{name}: нет поля **{PLACE_KEY}:** в мета-блоке")
elif task["section"] not in known:
errors.append(f"{name}: секция «{task['section']}» не совпадает ни с одной"
f" секцией {label} ({', '.join(sections)})")
elif entry and entry["section"] \
and entry["section"].lower() != task["section"]:
errors.append(f"{name}: секция в файле «{task['section']}»,"
f" а в {label} — «{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["why"] and entry["why"] != task["why"]:
errors.append(f"{name}: «зачем» разошлось (истина в файле)\n"
f" файл: {task['why']}\n"
f" индекс: {entry['why']}")
elif entry and not task["why"] and entry["why"]:
errors.append(f"{name}: «зачем» есть в {label}, а в файле нет —"
f" `check --fix` перенесёт его в мета-блок")
elif not task["why"]:
notes.append(f"{name}: без «зачем» — по нему выбирают задачу"
f" (`tasks.py edit {name[:-3]} --why …`)")
# 3. Прежние теги направления. Цель была зонтиком над параллельными
# направлениями; списку, который линеен, зонтик не нужен.
for tag, why in ((task["legacy_goal"], "цели упразднены вместе с роадмапом"),
(LEGACY_DECOMPOSED_TAG if LEGACY_DECOMPOSED_TAG in task["tags"]
else "", "тег отличал разобранную цель от пустой")):
if tag:
errors.append(f"{name}: тег «{tag}» — {why}; снимет `check --fix`")
# 3а. Схема тела — только замечанием: обязательным раздел становится
# там, где по нему принимают решение, а решение это `ready` на входе
# в работу. Лишний раздел говорит о неверном типе, и сказать об этом
# стоит сразу, не дожидаясь взятия.
if task["type"] in TYPES:
notes += schema_verdict(lay, task)[1]
# 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']}» нет — снять тег?")
if BODY_PLACEHOLDER in task["text"]:
notes.append(f"{name}: тело не дописано (остался плейсхолдер add)")
for name, entry in entries.items():
if name not in tasks:
errors.append(f"{label}:{entry['line']}: ссылка на несуществующий"
f" {lay.cfg['items']}/{name}")
errors += index_lint(lines, label)
if raw_last(lines, raw_names) != lines:
errors.append(f"{label}: сырьё (`{RESEARCH}` без раздела"
f" «{lay.cfg['question_heading']}») стоит не в конце своей"
f" секции — его не берут, и между берущимся оно требует"
f" открыть файл, чтобы это понять; переставит `check --fix`")
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}, стадия"
f" {STAGE_RU.get(lay.stage, '—')}, файлов {len(tasks)},"
f" строк {label} {len(entries)}")
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 sections_verdict(lay: Layout, sections: list[str], label: str) -> list[str]:
"""Состав секций беклога — и он производен от стадии.
На стройке беклог это **один** список от базы к деталям: порядок строк здесь
зависимость, и разложенный по полкам он перестаёт быть планом — сравнить два
шага из разных секций уже нельзя. На доработке полки законны и называет их
проект: смысла они не несут, это раскладка домена.
Слить секции машина не берётся: в каком порядке пойдут строки слитых полок,
знает человек, а порядок здесь и есть содержание.
"""
if not sections:
return [f"{label}: нет ни одной секции — заводить запись некуда"
f" (`tasks.py check --fix` этого не чинит: имя секции — дело проекта)"]
if lay.stage == BUILD and len(sections) > 1:
return [f"{label}: секций {len(sections)} ({', '.join(sections)}), а на"
f" стройке беклог — один список от базы к деталям: порядок строк"
f" здесь зависимость, и по полкам он перестаёт быть планом."
f" Слей секции руками (в каком порядке — знаешь только ты) или"
f" объяви доработку: `tasks.py stage {SUPPORT}`"]
return []
def health(lay: Layout, tasks: dict, entries: dict, sections: list[str]) -> None:
"""Метрики здоровья: размер секций, открытые вопросы, залежалость.
Механизирует то, что иначе держится на дисциплине."""
backlog = [t for n, t in tasks.items() if n in entries]
by_section = {s.lower(): 0 for s in sections}
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))
by_type: dict[str, int] = {}
for t in tasks.values():
by_type[t["type"] or "без типа"] = by_type.get(t["type"] or "без типа", 0) + 1
if by_type:
raw = sum(1 for t in tasks.values() if raw_research(lay, t))
print(" типы: " + ", ".join(f"{k} {by_type[k]}" for k in (*TYPES, "без типа")
if k in by_type)
+ (f" (сырьём, без «{lay.cfg['question_heading']}», {raw})" if raw else ""))
# Готовность к взятию — та же проверка, что откажет `ready`. Число, а не
# перечень: оно отвечает на «есть ли что брать сегодня», и когда ответ
# «нет», разбирать надо не список, а порцию груминга.
if backlog:
ready = [t for t in backlog
if t["type"] in TAKEABLE and not questions_open(lay, t)
and not schema_verdict(lay, t)[0]]
print(f" готово к взятию: {len(ready)} из {len(backlog)}"
+ ("" if len(ready) == len(backlog)
else " — прочим не хватает разделов своего типа или они ждут"
" ответа на вопрос"))
# Пустой беклог значит на двух стадиях разное, и молчать об этом нельзя:
# на доработке это норма, на стройке — событие, ради которого стадия и
# заведена.
elif lay.stage == BUILD:
print(f" беклог стройки пуст — план исчерпан, приложение построено."
f" Дальше доработка: `tasks.py stage {SUPPORT}`")
# Схема типа — **своя** строка, а не дубль предыдущей. «Готово к взятию»
# валит запись за что угодно (открытый вопрос, разделы), эта называет ровно
# одну причину. Строка нужна потому, что обязательность разделов проверяет
# только `ready` на входе в работу, а между заведением и взятием запись
# иначе не судит никто.
bad_schema = {n: t for n, t in tasks.items()
if t["type"] in TYPE_SCHEMA and schema_verdict(lay, t)[0]}
if bad_schema:
unfit = sorted(n[:-3] for n in bad_schema)
# Сырьё названо отдельно: схему оно не выполняет по определению («Вопрос»
# пуст — тем оно и сырьё), и без этой оговорки счётчик читался бы как
# число недоделанных задач, хотя часть его — записи, ещё не ставшие ими.
crude = sum(1 for t in bad_schema.values() if raw_research(lay, t))
print(f" схема типа не выполнена: {len(unfit)} из {len(tasks)}"
f" ({', '.join(unfit[:5])}{', …' if len(unfit) > 5 else ''})"
+ (f", сырья из них {crude}" if crude else "")
+ " — нет разделов, которых требует тип; чего именно, скажет"
" `tasks.py ready <слаг>`")
questions = [n for n, t in tasks.items() if questions_open(lay, t)]
if questions:
print(f" с открытым вопросом: {len(questions)}"
f" — в работу не берутся, разбор первым шагом груминга")
# Форма заголовка — счётчиком, а не замечанием на файл. Правило верное, но
# проверка эвристическая, а беклог, заведённый до правила, переоформляют не
# «заодно»: десятки одинаковых замечаний научили бы пропускать весь блок.
flat = sorted(n[:-3] for n, t in tasks.items()
if t["type"] in ACTION_TYPES and not action_title(t["bare"]))
if flat:
print(f" заголовков не в форме действия: {len(flat)}"
f" ({', '.join(flat[:5])}{', …' if len(flat) > 5 else ''})"
f" — задача отвечает на «что нужно сделать»:"
f" «Печатать поле одним куском», а не «Поле печатается одним куском»")
# Залежалость считается только на доработке. На стройке лежать долго —
# нормальное состояние шага, до которого ещё не дошла очередь: он стоит там,
# где стоит, по зависимости, и переоценивать его нечем.
dates = touched_map(lay)
if not dates or lay.stage == BUILD:
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, index_sections = parse_entries(read_lines(lay.index("backlog")))
order = {s.lower(): i for i, s in enumerate(index_sections)}
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["listed"] = "" if name in entries else "нет строки"
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.raw and not raw_research(lay, t):
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"]))
# Отбор работает на любой стадии, но значит он разное, и молчать об этом
# нельзя: на стройке шаг лежит долго законно — до него не дошла очередь,
# и он стоит там, где стоит, по зависимости.
if lay.stage == BUILD:
print(" стройка: залежалость здесь не мера — шаг ждёт своей"
" очереди по зависимости, а не потому, что его обходят\n")
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 ""
# Тип не печатается, когда по нему уже отобрали: колонка, одинаковая во
# всех строках, только съедает ширину под заголовок.
rtype = "" if a.type else f"{t['type'] or '—':<9}"
flag = " ?" if questions_open(lay, t) else ("~" if raw_research(lay, t) else " ")
print(f"{touched}{t['listed']:<11}{t['section']:<12}{rtype}{flag:<2}"
f"{t['path'].stem:<44} {t['bare']}")
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(rtype: str, section: str, reason: str, why: str, tags: list[str]) -> str:
"""Мета-блок: тип первой строкой, дальше место, «зачем» и теги.
Тип стоит первым не для красоты: он решает, что у записи вообще может быть
— какие разделы обязательны, берётся ли она в работу, — и читается раньше
всего остального.
"""
out = [f"- **Тип:** {rtype}"] if rtype else []
out.append(f"- **{PLACE_KEY}:** {section}" + (f" — {reason}" if reason else ""))
if why:
out.append(f"- **Зачем:** {why}")
if tags:
out.append("- **Теги:** " + ", ".join(tags))
return "\n".join(out)
def section_headers(lines: list[str]) -> list[tuple[int, str]]:
return [(i, m.group(1)) for i, line in enumerate(lines) if (m := SECTION.match(line))]
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 section_at(lines: list[str], i: int) -> str:
"""Секция, в которой лежит строка `i`, — ближайший заголовок выше неё.
Дом у этого вопроса один: его задаёт и перестановка внутри секции
(`move` без `--section`), и починка «строка не в своей секции»."""
return next((m.group(1) for j in range(i, -1, -1)
if (m := SECTION.match(lines[j]))), "")
def find_entry_index(lines: list[str], slug: str) -> int | None:
for i, line in enumerate(lines):
m = INDEX_ENTRY.match(line)
if m and Path(m.group(2)).name == f"{slug}.md":
return i
return None
def locate(lay: Layout, slug: str) -> tuple[list[str], int] | None:
"""Строки беклога и позиция строки задачи в них. None — строки нет."""
lines = read_lines(lay.index("backlog"))
ei = find_entry_index(lines, slug)
return None if ei is None else (lines, ei)
def insert_entry(lines: list[str], section: str, entry: str,
after: str | None = None, first: bool = False) -> None:
"""Вставляет строку в секцию: по умолчанию в конец, --after <слаг> — следом
за указанной строкой, --first — первой. Позиция значима прежде всего в
беклоге: порядок строк там и есть приоритет (правило 4), и назначает его
человек — отсюда и умолчание «в конец», а не «наверх»."""
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 = next((j for j in range(hi + 1, end) if lines[j].strip()), hi + 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_rebuilt(lines: list[str], section: str | None = None, reason: str | None = None,
why: str | None = None, tags: list[str] | None = None,
rtype: str | None = None) -> list[str] | None:
"""Строки файла с пересобранным мета-блоком. None — меты нет.
Пересобирается **весь блок**, а не правится по месту: заодно старая форма
(всё одной строкой через `·`) переезжает в новую, а поле места получает
нынешнее имя — «Категория» вместо «Секции», доставшейся от целей.
Нераспознанные поля переносятся как есть, с их написанием ключа, и встают
после известных — терять чужое поле нельзя, но и порядок ему диктовать
незачем.
`None` в аргументе — «не трогать», пустая строка — «убрать поле». Ничего не
пишет: запись — дело Plan.
"""
span = meta_span(lines)
if span is None:
return None
old = meta_fields(lines, span)
cur_section, cur_reason, cur_why, cur_tags, cur_type, extra = "", "", "", "", "", []
seen_section = False
for key, value in old:
low = key.lower()
if low in TYPE_KEYS:
cur_type = value.strip().lower()
elif low in PLACE_KEYS:
cur_section, _, cur_reason = (p.strip() for p in value.partition("—"))
seen_section = True
elif low in WHY_KEYS:
cur_why = value
elif low in ("теги", "tags"):
cur_tags = value
else:
extra.append(f"- **{key}:** {value}")
if not seen_section:
return None # мета без места сломана; `check` скажет это словами
new_type = rtype if rtype is not None else cur_type
out = [f"- **Тип:** {new_type}"] if new_type else []
out.append(f"- **{PLACE_KEY}:**"
f" {section if section is not None else cur_section}")
rsn = reason if reason is not None else cur_reason
if rsn:
out[-1] += f" — {rsn}"
new_why = why if why is not None else cur_why
if new_why:
out.append(f"- **Зачем:** {new_why}")
new_tags = ", ".join(tags) if tags is not None else cur_tags
if new_tags:
out.append(f"- **Теги:** {new_tags}")
return [*lines[:span[0]], *out, *extra, *lines[span[1]:]]
def meta_updated(path: Path, section: str | None = None, reason: str | None = None,
why: str | None = None, tags: list[str] | None = None,
rtype: str | None = None) -> str | None:
lines = path.read_text(encoding="utf-8").splitlines()
out = meta_rebuilt(lines, section=section, reason=reason, why=why, tags=tags,
rtype=rtype)
return None if out is None else "\n".join(out) + "\n"
def entry_line(lay: Layout, title: str, slug: str, why: str) -> str:
link = f"{lay.cfg['items']}/{slug}.md"
return f"- [{title}]({link})" + (f" — {why}" if why else "")
# Подсказка в шаблоне — по ключу конфига, один текст на раздел. Держать её
# рядом со схемой, а не расписывать шаблон на каждый тип: тип решает, какие
# разделы положить, а что писать в разделе, от типа не зависит.
SECTION_HINT = {
"surface_heading": "границы, которых изменение касается: эндпоинт или"
" команда, таблица и миграция, формат на диске, публичный"
" тип пакета, внешний сервис. Названы границы, а не то, как"
" они изменятся: план реализации живёт в предложении",
"criteria_heading": "проверяемые утверждения списком, у каждого назван оракул",
"repro_heading": "что сделать, чтобы расхождение проявилось, и что при этом"
" видно вместо ожидаемого. Не воспроизводится — это research,"
" а не fix",
"question_heading": "вопрос, на который отвечает эта разведка, — одной фразой."
" Пока его нет, это сырьё: в работу не берут",
"answer_heading": "куда ляжет ответ: docs/research/<тема>.md, ADR, тело этой"
" задачи. Приёмка разведки — записанный ответ, а не"
" изменённый код",
"scope_heading": "одна строка: чего касаться нельзя, что перезапускается, что"
" считается необратимым",
}
BODY_LEAD = {
"feature": "задача в одной фразе: что станет наблюдаемо иначе",
"fix": "что расходится с заявленным — в одной фразе",
"chore": "что обслуживаем и что перестанет мешать; адресат здесь"
" разработчик, и это законно",
RESEARCH: "о чём разведка: что непонятно и почему это мешает решать",
}
def body_template(rtype: str, lay: Layout) -> str:
"""Шаблон тела по схеме типа: обязательные разделы плюс «Рамки».
Шаблон и проверка растут из одного TYPE_SCHEMA: разойтись им нельзя, иначе
`add` кладёт то, на чём `ready` потом откажет.
"""
schema = TYPE_SCHEMA.get(rtype, TYPE_SCHEMA["feature"])
out = [f"<!-- {BODY_LEAD.get(rtype, BODY_LEAD['feature'])} -->"]
keys = list(schema["required"])
if "scope_heading" in schema["allowed"]:
keys.append("scope_heading")
for key in keys:
hint = SECTION_HINT[key]
if key == "criteria_heading":
hint = (f"{CRITERIA_MIN}{CRITERIA_MAX} проверяемых утверждений списком,"
f" у каждого назван {lay.cfg['oracle_word']}")
out.append(f"## {lay.cfg[key]}\n\n<!-- {hint} -->")
return "\n\n".join(out) + "\n"
def cmd_add(lay: Layout, a: argparse.Namespace) -> int:
for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_why(a.why),
bad_tags(a.tag), bad_reason(a.reason), bad_type(a.type)):
if err:
raise Usage(err)
if not a.title.strip():
raise Usage("пустой заголовок")
rtype = a.type.strip().lower()
path = lay.items / f"{a.slug}.md"
if path.exists():
raise Usage(f"{path.name} уже существует — дедуп: допиши в него, а не заводи новый")
lines = read_lines(lay.index("backlog"))
if not lines:
raise Usage(f"нет индекса {lay.name('backlog')} — прогони tasks.py init")
if find_entry_index(lines, a.slug) is not None:
raise Usage(f"строка в {lay.name('backlog')} для {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)) or "ни одной"
raise Usage(f"нет секции «{section}» в {lay.name('backlog')} (есть: {avail})")
tags = split_tags(a.tag)
if QUESTION_TAG in tags:
print(f" тег «{QUESTION_TAG}»: не забудь раздел «{lay.cfg['questions_heading']}» в теле")
title_full = h1_of(rtype, a.title)
meta = build_meta(rtype, section, a.reason or "", a.why or "", tags)
insert_entry(lines, section, entry_line(lay, title_full, a.slug, a.why or ""))
# Сырьё держится в конце секции сразу, а не до ближайшего `check --fix`:
# заводимая разведка вопроса ещё не несёт, а заводимая задача не должна
# вставать после неё.
raw = {n for n, t in tasks_of(lay).items() if raw_research(lay, t)}
if rtype == RESEARCH:
raw.add(f"{a.slug}.md")
lines[:] = raw_last(lines, raw)
plan = Plan()
plan.file(path, f"# {title_full}\n\n{meta}\n\n{body_template(rtype, lay)}")
plan.index(lay, "backlog", lines)
plan.commit()
print(f"создано: {lay.cfg['items']}/{a.slug}.md, строка в"
f" {lay.name('backlog')} (секция «{section}»); допиши тело редактором")
# Куда встала строка, значит на двух стадиях разное, и сказать это надо
# там же, где строка появилась: на стройке место в списке это зависимость,
# и «в конец» для шага, который делается раньше прочих, просто неверно.
if lay.stage == BUILD:
print(f" строка в конце списка — на стройке порядок это зависимость:"
f" если шаг делается раньше, переставь его"
f" (`tasks.py move {a.slug} --after <слаг>`)")
if not a.why:
print(f" без «зачем» — задай: tasks.py edit {a.slug} --why …")
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_why(a.why),
bad_tags(a.add_tag), bad_tags(a.rm_tag), bad_type(a.type)):
if err:
raise Usage(err)
if all(v is None for v in (a.title, a.why, a.type, a.add_tag, a.rm_tag)):
raise Usage("нечего менять: дай --title, --why, --type,"
" --add-tag или --rm-tag")
path = lay.items / f"{a.slug}.md"
if not path.exists():
raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/")
place = locate(lay, a.slug)
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"]
rtype = task["type"] if a.type is None else a.type.strip().lower()
h1 = h1_of(rtype, 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.type is not None:
tags = [t for t in tags if not t.startswith(LEGACY_KIND_TAG)]
why = task["why"] if a.why is None else a.why
# Написание секции берётся как есть, а не в нижнем регистре: имя секции
# принадлежит **заголовку индекса**, и мета на него только ссылается (тот же
# довод, что у шага 6 `apply_fixes`). Нижний регистр уезжал бы в файл
# «Категория: ядро», а следующий `check --fix` чинил бы за собственной
# правкой. Сверка принадлежности всё равно идёт по нижнему регистру.
section = task["section_raw"]
# Строки индекса может не быть, и это не всегда поломка: запись, пережившая
# упразднение своего индекса, лежит файлом без строки, а `check --fix`
# восстановить её не может — секция в мете указывает на исчезнувшую полку.
# Отказ здесь запирал бы такую запись навсегда: строку не восстановить, пока
# не сменишь тип, и тип не сменить, пока нет строки. Заводим строку сами, в
# первую секцию, и говорим об этом.
lost_section = ""
if place is None:
heads = section_headers(read_lines(lay.index("backlog")))
if not heads:
raise Usage(f"строки для {a.slug} нет, и в {lay.name('backlog')} нет"
f" ни одной секции — заводить её некуда")
if find_section(read_lines(lay.index("backlog")), section)[0] is None:
lost_section, section = section or "—", heads[0][1]
# Тип передаётся всегда, а не только при `--type`: у файла, не переехавшего
# на поле, он выведен из прежнего дома, и без него пересборка меты потеряла
# бы его вовсе.
new_text = meta_updated(path, section=section if section else None,
why=why if a.why is not None else None,
tags=tags if tags != task["tags"] else None,
rtype=rtype or None)
if new_text is None:
raise Usage(f"{a.slug}.md без поля **{PLACE_KEY}:** в мете —"
f" прогони check и почини")
tlines = new_text.splitlines()
tlines[0] = f"# {h1}"
new_text = "\n".join(tlines) + "\n"
# Место сырья — конец секции, и оно производно от типа: смена типа обязана
# переставить строку сразу, иначе индекс уезжает в дрейф на ровном месте.
raw_now = {n for n, t in tasks_of(lay).items() if raw_research(lay, t)}
raw_now.discard(f"{a.slug}.md")
if rtype == RESEARCH and not task["body"].get(lay.cfg["question_heading"].lower()):
raw_now.add(f"{a.slug}.md")
if place is None:
lines = read_lines(lay.index("backlog"))
insert_entry(lines, section, entry_line(lay, h1, a.slug, why))
else:
lines, ei = place
lines[ei] = entry_line(lay, h1, a.slug, why)
plan = Plan()
plan.file(path, new_text)
plan.index(lay, "backlog", raw_last(lines, raw_now))
plan.commit()
changed = [n for n, v in (("заголовок", a.title), ("зачем", a.why), ("тип", a.type),
("теги", a.add_tag or a.rm_tag))
if v is not None]
print(f"{a.slug}: обновлено ({', '.join(changed)})")
if place is None:
print(f" строки в {lay.name('backlog')} не было — заведена в секции"
f" «{section}», в конец: позицию назначает человек"
+ (f" (секции «{lost_section}» из меты в индексе нет)"
if lost_section else ""))
if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"]:
print(f" вопрос открыт — в работу задача не берётся, пока он не разобран"
f" (`tasks.py ready {a.slug}` это и скажет)")
return EXIT_OK
def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
"""Перестановка строки: внутри своей секции или в другую.
**Что значит перестановка, говорит стадия, а не эта команда**: на стройке она
называет зависимость («этот шаг делается после того»), на доработке —
приоритет («это берут раньше»). Отсюда и `--reason`: причина у двух движений
разная, и через месяц её не восстановить.
`--section` необязателен, и это не удобство. Перестановка внутри секции —
самая частая операция и там и там, а требовать в ней повторить текущую секцию
значит приглашать указать не ту: перенос в чужую секцию выглядел бы ровно так
же. Без `--section` секция берётся из индекса — та, в которой строка уже
лежит.
"""
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']}/")
place = locate(lay, a.slug)
if place is None:
raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix")
lines, ei = place
label = lay.name("backlog")
if a.section is None:
section = section_at(lines, ei)
if not section:
raise Usage(f"строка {a.slug} в {label} стоит до первой"
f" секции — переставлять внутри нечего. Назови секцию:"
f" tasks.py move {a.slug} --section <секция> --reason …")
else:
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}» в {label} (есть: {avail})")
task = parse_task(path)
# Место сырья производно от типа, а не назначается: назначить его — значит
# получить дрейф, который следующий же `check --fix` отменит, стерев решение
# человека. Поэтому отказ, и с названным выходом: сырьё перестаёт быть
# сырьём, как только у него появляется «Вопрос».
if (a.after or a.first) and raw_research(lay, task):
raise Usage(f"{a.slug} — сырьё (`{RESEARCH}` без раздела"
f" «{lay.cfg['question_heading']}»), и место у него не"
f" назначается: конец секции, потому что его не берут."
f" Допиши «{lay.cfg['question_heading']}» — и переставляй")
new_text = meta_updated(path, section=section, reason=a.reason,
rtype=task["type"] or None)
if new_text is None:
raise Usage(f"{a.slug}.md без поля **{PLACE_KEY}:** в мете —"
f" прогони check и почини")
entry = lines.pop(ei)
try:
insert_entry(lines, section, entry, a.after, a.first)
except KeyError as e:
raise Usage(f"--after {a.after}: такой строки в секции «{section}» нет") from e
# Сырьё сносится в конец **всегда**, в том числе после `--after`/`--first`:
# переставленная строка от этого не двигается (она не сырьё — отказ выше),
# а вот сырьё, оказавшееся выше неё, встаёт на своё место сразу, а не до
# ближайшего `check --fix`.
lines[:] = raw_last(lines, {n for n, t in tasks_of(lay).items()
if raw_research(lay, t)})
plan = Plan()
plan.file(path, new_text)
plan.index(lay, "backlog", lines)
plan.commit()
where = ("первой" if a.first else f"после {a.after}" if a.after else "в конец")
print(f"{a.slug}: " + (f"перенесено в «{section}»" if a.section is not None
else f"переставлено внутри «{section}»")
+ f", {where} ({label})")
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']}/")
# Строки может не быть, и отказ здесь запирал бы запись навсегда: чтобы
# строку восстановить, надо её куда-то класть, а класть незачем — запись
# закрывают. Так закрывается и то, что пережило упразднение своего индекса.
place = locate(lay, a.slug)
task = parse_task(path)
plan = Plan()
today = datetime.date.today().isoformat()
if a.reason:
reason = a.reason.rstrip()
dot = "" if reason.endswith((".", "!", "?")) else "."
bullet = (f"- {today} `{a.slug}` — {task['title']}. Причина: {reason}{dot}"
f" Была секция: {task['section_raw'] 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")
if place is not None:
lines, ei = place
lines.pop(ei)
plan.index(lay, "backlog", lines)
plan.delete(path)
plan.commit()
print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}")
if place is None:
print(f" строки в {lay.name('backlog')} не было — удалён только файл")
if not a.reason:
print(" дорога назад: файл восстанавливается из git —"
f" `tasks.py reopen {a.slug} --reason «приёмка не сошлась: …»`")
return EXIT_OK
def git_deleted_text(path: Path) -> str | None:
"""Текст закрытой задачи из истории git.
Два источника, и второй обязателен. Коммит удаления — обычный случай:
закрытие уже уехало в историю. Но пайплайн закрывает задачу **последним
шагом**, и между удалением файла и коммитом учёта есть окно, в котором
коммита удаления ещё нет, а текст лежит в `HEAD`. Без второго источника
`reopen` отказывал бы ровно на свежезакрытой задаче — то есть в самом
вероятном своём применении.
"""
try:
sha = subprocess.run(["git", "log", "--diff-filter=D", "--format=%H", "-n", "1",
"--", str(path)], capture_output=True, text=True).stdout.strip()
for rev in ([f"{sha}^"] if sha else []) + ["HEAD"]:
out = subprocess.run(["git", "show", f"{rev}:{path}"],
capture_output=True, text=True)
if out.returncode == 0:
return out.stdout
return 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
rtype = parse_task_text(text, path)["type"]
if a.reason:
upd = meta_updated_text(text, reason=a.reason, rtype=rtype or None)
if upd is None:
print(" внимание: меты нет, причина возврата не записана в файл")
else:
text = upd
tmp = parse_task_text(text, path)
# Написание — из меты как есть: имя секции уедет в доклад, а сверка
# принадлежности всё равно идёт по нижнему регистру (`find_section`).
section = tmp["section_raw"] or tmp["section"]
lines = read_lines(lay.index("backlog"))
heads = section_headers(lines)
lost_section = ""
if find_section(lines, section)[0] is None:
# Секции могло не стать законно: смена стадии переразмечает беклог, и
# задача, закрытая до перехода, ссылается на исчезнувшую полку. Отказ
# здесь означал бы, что закрытое до перехода не возвращается никогда, —
# а `reopen` заведён ровно на случай, когда приёмка не сошлась. Кладём в
# первую секцию, правим мету и говорим об этом вслух.
if not heads:
raise Usage(f{lay.name('backlog')} нет ни одной секции —"
f" возвращать некуда")
lost_section, section = section, heads[0][1]
rebuilt = meta_rebuilt(text.splitlines(), section=section)
if rebuilt is not None:
text = "\n".join(rebuilt) + "\n"
tmp = parse_task_text(text, path)
plan = Plan()
plan.file(path, text)
if find_entry_index(lines, a.slug) is None:
insert_entry(lines, section, entry_line(lay, title, a.slug, tmp["why"]))
# Место сырья производно от типа, и `reopen` обязан его соблюсти сразу:
# вернуть разведку без «Вопроса» просто в конец секции — значит
# поставить её после сырья, лежавшего там раньше, и получить ошибку
# `check` на ровном месте. Возвращаемого файла ещё нет на диске, поэтому
# он добавляется к набору вручную.
raw = {n for n, t in tasks_of(lay).items() if raw_research(lay, t)}
if raw_research(lay, tmp):
raw.add(f"{a.slug}.md")
plan.index(lay, "backlog", raw_last(lines, raw))
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('backlog')} из истории git"
f" (секция «{section}», в конец: позицию назначает человек)")
if lost_section:
print(f" секции «{lost_section}» в беклоге больше нет — положен в"
f" «{section}», «{PLACE_KEY}» в файле поправлена. Так бывает после"
f" смены стадии: состав секций там переразмечается")
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, rtype: str | None = None) -> str | None:
"""То же для текста, которого ещё нет на диске (возврат задачи из git)."""
out = meta_rebuilt(text.splitlines(), reason=reason, rtype=rtype)
return None if out is None else "\n".join(out) + "\n"
# --- Готовность записи к работе ---
def cmd_ready(lay: Layout, a: argparse.Namespace) -> int:
"""Схема типа выполнена — запись можно брать в работу.
Прежде этот гейт стоял на взятии задачи в спринт: набор и был моментом,
когда запись впервые судили целиком. Спринтов нет, а момент нужен — иначе задача
уезжает в работу без критериев приёмки, и узнают об этом на приёмке, когда
сверять уже не с чем. Теперь момент называет тот, кто берёт: скилл решения
задачи зовёт `ready` первым делом.
Отказ здесь — **рабочая ситуация**, а не ошибка употребления: запись просто
ещё не дописана. Поэтому код 1, а не 2, и ветвиться на них надо по-разному.
"""
verdicts, 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)
errs = []
if not t["type"]:
errs.append(f"тип не назван — `edit {slug} --type {'|'.join(TAKEABLE)}`."
f" Тип решает, каких разделов запись обязана иметь,"
f" и без него проверять нечего")
elif t["type"] not in TAKEABLE:
errs.append(f"тип «{t['type']}» вне словаря ({', '.join(TYPES)}) —"
f" какие разделы обязательны, неизвестно")
else:
# Отказ по факту, а не по метке: непустой раздел «Вопросы» держит
# запись независимо от тега. Забывший тег иначе проходил бы, а
# поставивший спотыкался — стимул ровно обратный правилу.
if questions_open(lay, t):
errs.append(f"непустой раздел «{lay.cfg['questions_heading']}» —"
f" вопрос разбирается до взятия. Отвечен — запиши ответ"
f" в тело и очисти раздел"
f" (тег снимается `edit {slug} --rm-tag {QUESTION_TAG}`)")
if QUESTION_TAG in t["tags"] and not questions_open(lay, t):
errs.append(f"тег «{QUESTION_TAG}» стоит, а раздела"
f" «{lay.cfg['questions_heading']}» нет — либо вопрос записан"
f" не туда, либо тег пора снять:"
f" `edit {slug} --rm-tag {QUESTION_TAG}`")
serrs, notes = schema_verdict(lay, t)
errs += serrs
warn += [f"{slug}: {n}" for n in notes]
verdicts.append((slug, errs))
bad = [(s, e) for s, e in verdicts if e]
for slug, errs in verdicts:
if errs:
print(f"НЕ ГОТОВА {slug}")
for e in errs:
print(f" {e}")
else:
print(f"готова {slug}")
for w in warn:
print(f" замечание: {w}")
if bad:
print(f"\nИтог: не готово {len(bad)} из {len(verdicts)}."
f" Дописывается это на груминге или тем, кто берёт задачу.")
return EXIT_DRIFT
return EXIT_OK
# --- check --fix ---
def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
"""Детерминированная починка дрейфа. Чинит только то, где истина
однозначно в файле или где источник ровно один:
- дубли строк на один файл, рассинхрон заголовка, задача не в своей секции;
- отсутствующая строка индекса — восстанавливается **вместе с «зачем»** из
меты файла;
- «зачем», оставшееся только в индексе, — переносится в файл (миграция со
старого формата: другого экземпляра нет, неоднозначности тоже);
- теги упразднённых целей (`goal:<слаг>`, `decomposed`) — снимаются.
Неоднозначное (ссылка на исчезнувший файл, битые строки, запись типа `goal`)
не трогает — это на суд человека, и о нём говорится вслух.
"""
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()
def staged_lines(task: dict) -> list[str]:
"""Текущий текст файла с учётом уже отложенных правок.
Перечитать файл с диска посреди прохода значит стереть то, что положил
предыдущий шаг: шагов, правящих мету, четыре, и каждый видит свою
часть.
"""
staged = files.get(task["path"])
return (staged.splitlines() if staged is not None
else task["path"].read_text(encoding="utf-8").splitlines())
def stage(task: dict, **kw) -> bool:
"""Отложить пересборку меты. False — меты нет, чинить нечем."""
rebuilt = meta_rebuilt(staged_lines(task), **kw)
if rebuilt is None:
return False
files[task["path"]] = "\n".join(rebuilt) + "\n"
return True
# 0. Форма меты. Старая (всё одной строкой через `·`) переписывается
# списком. Правка чисто механическая: поля те же, включая нераспознанные.
for name, task in tasks.items():
if not task["legacy_meta"]:
continue
if not stage(task, rtype=task["type"] or None):
ambiguous.append(f"{name}: мета одной строкой и без поля места"
f" (**{PLACE_KEY}:**) — переписать нечего,"
f" чинится руками")
continue
fixed.append(f"{name}: мета переписана списком")
# 0а. Тип переезжает в свой дом. Прежние дома — тег `kind:<род>` и префикс
# заголовка — читаются, но больше не пишутся; заодно заголовок получает
# эмодзи, а поле места — имя по типу. Тип, который не выводится
# ниоткуда, машина не угадывает: `feature` от `chore` отличает человек,
# и подставленное наугад значение врало бы ровно там, где по нему
# принимают решение.
for name, task in tasks.items():
rtype = task["type"]
# Мёртвые теги и прежнее имя поля места снимаются у **любой** записи,
# включая ту, чей тип машина исправить не берётся. Тег `goal:` мёртв
# независимо от того, что за запись его несёт, а «Секция» — прежнее имя
# того же самого поля. Стой эта правка после разбора типа, перевод
# проекта оставлял бы их ровно в файлах целей — то есть в тех, которые
# человек как раз и разбирает руками, и разбирал бы он их с мусором.
want_key = PLACE_KEY.lower()
tags = [t for t in task["tags"]
if not t.startswith((LEGACY_KIND_TAG, LEGACY_GOAL_TAG))
and t != LEGACY_DECOMPOSED_TAG]
renamed = bool(task["place_key"]) and task["place_key"] != want_key
retyped = bool(rtype) and rtype in TYPES and task["meta_type"] != rtype
if tags != task["tags"] or renamed or retyped:
if not stage(task, rtype=rtype if retyped else None,
tags=tags if tags != task["tags"] else None):
ambiguous.append(f"{name}: чинить мету некуда — в файле нет"
f" мета-блока")
else:
what = []
if retyped:
what.append(f"тип «{rtype}» в поле **Тип:**")
if task["legacy_kind"]:
what.append(f"снят тег {LEGACY_KIND_TAG}{task['legacy_kind']}")
if task["legacy_goal"]:
what.append(f"снят тег {task['legacy_goal']}")
if LEGACY_DECOMPOSED_TAG in task["tags"]:
what.append(f"снят тег {LEGACY_DECOMPOSED_TAG}")
if renamed:
what.append(f"поле места → «{PLACE_KEY}»")
fixed.append(f"{name}: " + ", ".join(what))
if not rtype:
ambiguous.append(f"{name}: тип не выводится — нет ни поля **Тип:**, ни"
f" тега {LEGACY_KIND_TAG}<род>, ни префикса заголовка."
f" Назови руками: `edit {name[:-3]} --type"
f" {'|'.join(TYPES)}`")
continue
if rtype == LEGACY_GOAL:
ambiguous.append(f"{name}: тип «{LEGACY_GOAL}» упразднён вместе с"
f" роадмапом — во что превращается эта запись,"
f" решает человек: в задачу или в ничто")
continue
if rtype not in TYPES:
ambiguous.append(f"{name}: тип «{rtype}» вне словаря"
f" ({', '.join(TYPES)}) — чем он заменяется,"
f" решает человек")
continue
want_h1 = h1_of(rtype, task["bare"])
if task["title"] and task["title"] != want_h1:
src = staged_lines(task)
if src and src[0].startswith("#"):
src[0] = f"# {want_h1}"
files[task["path"]] = "\n".join(src) + "\n"
# Шаг 2 сверяет строку индекса с этим полем — иначе индекс
# остался бы с прежним заголовком до следующего прогона.
task["title"] = want_h1
fixed.append(f"{name}: заголовок получил эмодзи типа — {want_h1}")
# 1. Дубли строк на один файл — оставляем первую.
for kind, lines in idx.items():
seen: set[str] = set()
out: list[str] = []
for line in lines:
m = INDEX_ENTRY.match(line)
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(line)
idx[kind] = out
# 2. «Зачем»: истина в файле. Если в файле нет, а в индексе есть — это
# старый формат, и единственный экземпляр надо спасти в файл.
for kind, lines in idx.items():
for i, line in enumerate(lines):
m = INDEX_ENTRY.match(line)
if not m:
continue
name = Path(m.group(2)).name
task = tasks.get(name)
if not task:
continue
idx_why = (m.group(3) or "").strip()
if not task["why"] and idx_why:
if not stage(task, why=idx_why):
ambiguous.append(f"{name}: «зачем» только в {lay.name(kind)},"
f" а в файле нет меты — перенести некуда")
continue
task["why"] = idx_why
fixed.append(f"{name}: «зачем» перенесено из {lay.name(kind)} в мету файла")
if m.group(1) != task["title"] or idx_why != task["why"]:
lines[i] = entry_line(lay, task["title"], name[:-3], task["why"])
fixed.append(f"{lay.name(kind)}: строка синхронизирована с файлом: {name}")
dirty.add(kind)
# 3. Нет строки вовсе / строка не в своей секции.
kind = "backlog"
for name, task in tasks.items():
ei = find_entry_index(idx[kind], name[:-3])
if ei is None:
if not task["section"]:
ambiguous.append(f"{name}: строки в индексе нет, и в файле"
f" нет секции — восстанавливать не по чему")
continue
hi, section = find_section(idx[kind], task["section"])
if hi is None:
ambiguous.append(f"{name}: строки нет, а секции «{task['section']}»"
f" нет в {lay.name(kind)} — восстанавливать некуда")
continue
insert_entry(idx[kind], section, entry_line(lay, task["title"], name[:-3],
task["why"]))
# Восстановленная строка встаёт в конец секции, и это надо сказать:
# позицию назначает человек, а машинная выдала бы себя за его
# решение. Чем именно она была бы — зависимостью или приоритетом, —
# решает стадия, и назвать её тут дешевле, чем заставлять вспоминать.
means = ("зависимость" if lay.stage == BUILD else
"приоритет" if lay.stage == SUPPORT else "порядок работ")
fixed.append(f"{lay.name(kind)}: восстановлена строка {name}"
f" — в конце секции, позицию назначь сам:"
f" порядок строк это {means}"
+ ("" if task["why"] else " (в файле нет «зачем» — допиши)"))
dirty.add(kind)
continue
if not task["section"]:
continue
hi, section = find_section(idx[kind], task["section"])
if hi is None:
continue
cur = section_at(idx[kind], ei)
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 kind, lines in idx.items():
if kind == "backlog" and lay.stage in STAGES:
span = stage_block_span(lines)
if span is not None and lines[span[0]:span[1]] != stage_block(lay.stage):
lines[span[0]:span[1]] = stage_block(lay.stage)
fixed.append(f"{lay.name(kind)}: шапка переписана под стадию"
f" «{STAGE_RU[lay.stage]}»")
dirty.add(kind)
raw = {n for n, t in tasks.items() if raw_research(lay, t)}
if (moved := raw_last(lines, raw)) != lines:
lines[:] = moved
fixed.append(f"{lay.name(kind)}: сырьё снесено в конец своей секции"
f" ({len(raw)} записей `{RESEARCH}` без раздела"
f" «{lay.cfg['question_heading']}»)")
dirty.add(kind)
if spaced_sections(lines) != lines:
fixed.append(f"{lay.name(kind)}: отбивка после заголовков секций")
dirty.add(kind)
# 5. Написание секции в мете. Имя секции принадлежит **заголовку индекса** —
# файл на секцию только ссылается, а принадлежность сверяется по нижнему
# регистру. Поэтому расхождение в одном регистре однозначно: побеждает
# заголовок.
for name, task in tasks.items():
if not task["section_raw"]:
continue
_, heading = find_section(idx["backlog"], task["section"])
if not heading or heading == task["section_raw"]:
continue
if not stage(task, section=heading):
continue
fixed.append(f"{name}: место в мете «{task['section_raw']}» → «{heading}»")
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 ---
BACKLOG_ORDER = {
BUILD: "**Порядок строк — зависимость:** это план стройки от базы к деталям,\n"
"и строка выше сделана раньше не потому, что важнее, а потому, что\n"
"иначе нельзя. Секция здесь **одна**: разложенный по полкам план\n"
"перестаёт быть планом. Список пишется вперёд целиком — это не\n"
"гниение беклога, а замысел. Пустой беклог значит, что стройка\n"
"окончена: дальше `tasks.py stage support`.",
SUPPORT: "**Порядок строк внутри секции — важность:** первая строка это то,\n"
"что делают следующим. Назначает его человек на груминге, машина не\n"
"выводит. Секции — полки домена, смысла они не несут. Заводится по\n"
"одной, по мере появления; пустой беклог — нормальное состояние.",
}
# Абзац шапки, объявляющий стадию, размечен парой комментариев — и это не
# украшение. Стадия решает, что значит порядок строк, а читают об этом **здесь**:
# индекс открывают вместо документации. Без разметки `stage` не знал бы, что
# переписывать, и абзац продолжал бы называть прежнюю стадию — молча и навсегда.
# С разметкой расхождение шапки с конфигом становится обычным дрейфом: `check`
# его называет, `check --fix` правит.
STAGE_OPEN = "<!-- стадия -->"
STAGE_CLOSE = "<!-- /стадия -->"
def stage_block(stage_name: str) -> list[str]:
return [STAGE_OPEN,
f"Стадия проекта — **{STAGE_RU[stage_name]}**"
f' (`[tasks] {STAGE_KEY} = "{stage_name}"`).',
BACKLOG_ORDER[stage_name],
STAGE_CLOSE]
def stage_block_span(lines: list[str]) -> tuple[int, int] | None:
"""Границы размеченного абзаца — `[начало, конец)`. None, если разметки нет."""
try:
start = next(i for i, ln in enumerate(lines) if ln.strip() == STAGE_OPEN)
end = next(i for i in range(start + 1, len(lines))
if lines[i].strip() == STAGE_CLOSE)
except StopIteration:
return None
return start, end + 1
def stage_block_verdict(lay: Layout, lines: list[str], label: str) -> list[str]:
"""Шапка беклога против конфига. Разметки нет — молчим: индекс мог быть
заведён до её появления, и требовать её от чужого файла не за что."""
span = stage_block_span(lines)
if span is None or lay.stage not in STAGES:
return []
want = "\n".join(stage_block(lay.stage))
if "\n".join(lines[span[0]:span[1]]).strip() == want.strip():
return []
return [f"{label}: шапка объявляет не ту стадию, что конфиг"
f" ({STAGE_RU[lay.stage]}) — а читают о смысле порядка строк"
f" именно её; перепишет `check --fix`"]
def init_files(lay: Layout, sections: list[str], stage_name: str) -> dict[Path, str]:
out: dict[Path, str] = {}
# Служебный файл здесь не заводится: его пишет `write_config` по живому
# файлу — версию двигает построчно, ключи дописывает, чужого не затирает.
# Планом это сделать нельзя, потому что план перезаписывает целиком, а
# перезапись стёрла бы комментарии — то, ради чего взят TOML.
out[lay.index("backlog")] = (
"# Беклог\n\n"
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
"+ строка здесь. Ведётся скиллом `av-dev:task-track`.\n\n"
+ "\n".join(stage_block(stage_name)) + "\n\n"
f"Одно место в очереди назначено не человеком, а типом: сырьё"
f" (`{RESEARCH}`\nбез раздела «{lay.cfg['question_heading']}») стоит в"
" конце секции — его не берут.\n\n"
"Тип записи стоит первым полем меты и решает, что у неё может быть:\n"
+ "".join(f"{TYPE_EMOJI[t]} `{t}` " for t in TAKEABLE) + "\n\n"
"Секции «блокеры» здесь нет и не заводится: блокер — это состояние\n"
"(работа не может продолжаться ни одной задачей), оно живёт до ответа\n"
"человека, а его следы — вопросами в файлах задач.\n\n"
+ "".join(f"## {s}\n\n" for s in sections))
out[lay.index("rejected")] = (
"# Ушедшее без реализации\n\n"
"Задачи, покинувшие беклог **без реализации**, с причиной и датой.\n"
"Пишется `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 adopt_cfg(lay: Layout) -> dict:
"""Что адаптация обязана записать о себе: где встал каталог.
Имён частей здесь нет — адаптация раскладывает всё по умолчаниям, — а путь
есть всегда, даже умолчательный: `--target` задаёт его свободно, и молча
записанное «tasks» указывало бы в пустоту.
"""
try:
rel = lay.root.resolve().relative_to(lay.project.resolve()).as_posix()
except ValueError:
return {}
return {DIR_KEY: rel}
def write_config(project: Path, cfg: dict) -> list[str]:
"""Записать версию и настройки каталога; вернуть строки доклада.
Файла нет — он заводится целиком скелетом, с комментариями. Файл есть — в
нём двигается версия и дописываются недостающие ключи секции; чужое
значение не затирается, но и не замалчивается: разошедшийся ключ уезжает в
доклад строкой, потому что `dir`, указывающий не туда, куда только что
заведён каталог, оставляет каталог недостижимым.
"""
path = project / CONFIG_NAME
if not path.is_file():
path.write_text(conf.skeleton(LAYOUT_VERSION, tasks=cfg), encoding="utf-8")
return [f"{CONFIG_NAME} заведён: версия {LAYOUT_VERSION}"
f"{', ' + ', '.join(sorted(cfg)) if cfg else ''}"]
out = []
if conf.version(conf.read(project)) != LAYOUT_VERSION:
conf.set_version(project, LAYOUT_VERSION)
out.append(f"версия раскладки в {CONFIG_NAME}: {LAYOUT_VERSION}")
clash = conf.missing_keys(project, "tasks", cfg)
added = conf.merge_section(project, "tasks", cfg)
if added:
out.append(f"дописано в [tasks]: {', '.join(added)}")
for key, had in sorted(clash.items()):
out.append(f"ВНИМАНИЕ [tasks] {key} = «{had}» оставлен как был, а каталог"
f" заведён под «{cfg[key]}» — поправь {CONFIG_NAME} руками,"
f" иначе скрипт пойдёт не туда")
return out or [f"{CONFIG_NAME} уже описывает эту раскладку"]
def cmd_init(root: Path, a: argparse.Namespace) -> int:
if not dir_within_cwd(root):
raise Usage(f"--dir вне рабочего каталога: {root}")
names = {k: v for k, v in (("items", a.items), ("backlog", a.backlog),
("rejected", a.rejected)) if v}
# Версия формата — первым ключом и всегда: каталог, заведённый сегодня,
# приведён к сегодняшнему формату, и объявить это должен тот, кто его завёл.
# Имена частей — следом и только те, что названы явно: умолчание, записанное
# в файл, стало бы вторым домом для того же имени. В раскладку версия не
# идёт — `Layout` про имена, и число среди имён там ничего не значит.
# Каталог задач называется ключом `dir`, если он не умолчательный: без него
# `.av-dev.toml` не сможет сказать, где искать, и разрешение уедет на
# умолчание — молча и в другой каталог.
project = conf.find_root() or Path.cwd().resolve()
stage_name = a.stage.strip().lower()
cfg = {**names, STAGE_KEY: stage_name}
try:
rel = root.resolve().relative_to(project).as_posix()
except ValueError:
raise Usage(f"каталог задач {root} вне проекта {project}") from None
if rel != DEFAULT_DIR:
cfg[DIR_KEY] = rel
lay = Layout(root, cfg, project, load_config(project))
if lay.index("backlog").exists():
raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён")
sections = uniq_sections(a.sections or DEFAULT_SECTIONS[stage_name])
if not sections:
raise Usage("пустой список секций")
# Секций на стройке ровно одна, и отказ здесь дешевле молчаливого выбора:
# порядок строк на стройке — зависимость, и разложенный по полкам список
# перестаёт быть планом.
if stage_name == BUILD and len(sections) > 1:
raise Usage(f"на стройке секция одна, а названо {len(sections)}"
f" ({', '.join(sections)}): беклог стройки — один список от"
f" базы к деталям. Полки заводятся при переходе в доработку")
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, stage_name).items():
plan.file(path, text)
plan.commit()
said = write_config(project, cfg)
print(f"каталог задач заведён: {root}, стадия {STAGE_RU[stage_name]}")
print(f" секции беклога: {', '.join(sections)}"
+ (" — порядок строк это зависимость" if stage_name == BUILD
else " — порядок строк внутри секции это важность"))
for line in said:
print(f" {line}")
return EXIT_OK
# --- stage: смена стадии проекта ---
def cmd_stage(lay: Layout, a: argparse.Namespace) -> int:
"""Показать стадию, объявить её впервые или сменить.
**Объявление и смена — разные операции, и различает их не флаг, а факт:**
была ли стадия названа раньше. Объявление беклога не трогает вовсе — оно
называет то, что уже верно, и переразметить при этом чужие полки значило бы
подменить ответ на вопрос вопросом о нём. Смена трогает шапку и конфиг, а
состав секций — **только если её об этом попросили** `--sections`.
Слить полки сам скрипт не берётся ни в одном из случаев (решение Р240): в
каком порядке пойдут строки слитых полок, знает человек. Отсюда и отказ на
стройке при нескольких секциях — с названным выходом, а не глухой.
"""
if a.to is None:
print(f"стадия: {STAGE_RU.get(lay.stage, 'не объявлена')}"
+ (f" ({lay.stage})" if lay.stage else ""))
print(" " + (BACKLOG_ORDER[lay.stage].replace("\n", " ") if lay.stage in STAGES
else f"объяви: tasks.py stage {BUILD}|{SUPPORT}"))
return EXIT_OK if lay.stage in STAGES else EXIT_DRIFT
want = a.to.strip().lower()
if want == lay.stage:
raise Usage(f"стадия уже «{STAGE_RU[want]}» ({want}) — менять нечего")
declaring = lay.stage not in STAGES
lines = read_lines(lay.index("backlog"))
heads = section_headers(lines)
if not heads:
raise Usage(f{lay.name('backlog')} нет ни одной секции — стадию"
f" объявлять не над чем. Заведи секцию заголовком «## …»"
f" и повтори")
current = [name for _, name in heads]
if a.sections is not None and declaring:
raise Usage("--sections при объявлении стадии не принимается: объявление"
" называет то, что уже верно, и беклог не переразмечает."
" Секции меняет `move <слаг> --section <секция> --reason …`")
sections = uniq_sections(a.sections) if a.sections is not None else current
if not sections:
raise Usage("пустой список секций")
if want == BUILD and len(sections) > 1:
raise Usage(
f"на стройке беклог — один список от базы к деталям, а секций"
f" {len(sections)}: {', '.join(sections)}. Слить их машина не берётся —"
f" порядок строк в слитом списке знает только человек. Либо слей сам"
f" (`move <слаг> --section <куда> --reason …`) и повтори, либо назови"
f" итоговую секцию явно: `stage {BUILD} --sections <имя>`"
+ (" (при объявлении стадии этот флаг не принимается —"
" сперва слей руками)" if declaring else ""))
merge = sections != current
plan = Plan()
skipped: list[str] = []
moved, retyped = 0, 0
if merge:
# Тело каждой секции — всё, что под её заголовком: и строки-пункты, и
# проза. Собирается оно в первую новую секцию в прежнем порядке секций:
# другого порядка машина не знает, а выдумать его значило бы переставить
# чужую очередь.
head = lines[:heads[0][0]]
body: list[str] = []
for k, (i, _) in enumerate(heads):
end = heads[k + 1][0] if k + 1 < len(heads) else len(lines)
body += lines[i + 1:end]
moved = sum(1 for line in body if INDEX_ENTRY.match(line))
lines = [*head]
for k, s in enumerate(sections):
lines.append(f"## {s}")
if k == 0:
lines += body
# Категория в файлах производна от заголовка индекса, и разъехаться ей
# нельзя: `check` назовёт это дрейфом на первой же записи.
for name, task in tasks_of(lay).items():
if task["section_raw"] == sections[0]:
continue
text = meta_updated(task["path"], section=sections[0],
rtype=task["type"] or None)
if text is None:
skipped.append(name) # мета не пересобирается — скажем вслух
continue
plan.file(task["path"], text)
retyped += 1
# Шапка объявляет стадию, и читают о смысле порядка строк именно её. Не
# переписать её значило бы оставить в индексе прямое враньё.
span = stage_block_span(lines)
if span is not None:
lines[span[0]:span[1]] = stage_block(want)
# Место сырья производно от типа, и слияние секций его нарушает: сырьё из
# второй полки оказывается в середине списка. Без этого шага переход
# оставлял бы каталог красным на ровном месте.
plan.index(lay, "backlog", raw_last(lines, {n for n, t in tasks_of(lay).items()
if raw_research(lay, t)}))
plan.commit()
# Конфиг: файл есть — правим ключ, файла нет — заводим скелетом. Иначе
# объявление стадии в проекте без `.av-dev.toml` рождало бы конфиг без
# версии раскладки, то есть меняло один отказ `check` на другой.
if (lay.project / CONFIG_NAME).is_file():
conf.set_section_key(lay.project, "tasks", STAGE_KEY, want)
said = [f"{CONFIG_NAME}: [tasks] {STAGE_KEY} = «{want}»"]
else:
said = write_config(lay.project, {STAGE_KEY: want})
print(f"стадия: {'объявлена' if declaring else STAGE_RU.get(lay.stage, '—') + ' →'}"
f" {STAGE_RU[want]}")
for line in said:
print(f" {line}")
if merge:
print(f" секции слиты в «{sections[0]}»: перенесено строк {moved},"
f" поправлена «{PLACE_KEY}» у {retyped} файлов")
else:
print(f" секции беклога не тронуты: {', '.join(sections)}")
if span is None:
print(f" шапка {lay.name('backlog')} не размечена ({STAGE_OPEN}) —"
f" абзац про стадию перепиши сам: он называет прежнюю")
for name in skipped:
print(f" НЕ ТРОНУТ {name}: мета не пересобирается (нет поля"
f" **{PLACE_KEY}:**) — «{sections[0]}» проставь сам")
if declaring:
print(" беклог не тронут: объявление называет то, что уже верно")
elif want == SUPPORT:
print(" порядок строк с этого момента значит важность, а не зависимость:"
" прежний план шёл по зависимости, и как очередь он не расставлен."
" Первый заход — груминг (скилл av-dev:task-groom)")
else:
print(" порядок строк с этого момента значит зависимость, а не важность:"
" выстрой список от базы к деталям")
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"^-?\s*\*\*(Приоритет|Секция|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, bare = title_parts(title)
if kind == LEGACY_IDEA:
kind = RESEARCH
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,
"why": entry.get("why", ""), "step": None,
"in_index": path.name in entries,
"translit": translit_ish(path.stem),
"questions_heading": qh,
"source": str(path),
})
if index_name:
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": [],
"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()
step = None
if (s := STEP.match(text)):
step, clean = int(s.group(1)), re.sub(r"[`*]", "", s.group(2)).strip()
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": "", "old_section": heading, "section": "", "reason": "",
"why": "", "in_index": False, "translit": False,
"questions_heading": "", "source": f"{path}:{num}", "body": text,
"done": state.lower() == "x", "step": step,
})
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))
stage_name = a.stage.strip().lower()
items, rejected, unclassified = [], [], []
for sc in scans:
items += sc["items"]
rejected += sc.get("rejected", [])
unclassified += sc.get("unclassified", [])
# Нумерованные шаги встают первыми и по своим номерам: их порядок назван
# источником, а порядок — единственное, чего из файла задачи не восстановить.
items.sort(key=lambda it: (it.get("step") is None, it.get("step") or 0))
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": 2,
"target": a.target,
"stage": stage_name,
"sources": [str(s) for s in sources],
"path_map": path_map,
"sections_backlog": uniq_sections(a.sections
or DEFAULT_SECTIONS[stage_name]),
"section_map": {},
"items": items,
"rejected": rejected,
"unclassified": unclassified,
}
print(f"адаптация: стадия {STAGE_RU[stage_name]}, найдено записей {len(items)},"
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:
steps = sum(1 for it in sc["items"] if it.get("step") is not None)
print(f" {sc['source']}: список — пунктов {len(sc['items'])},"
f" из них нумерованных {steps} (их порядок сохранён)")
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` (английский), `type` и `section`"
" у каждой записи, выстрой порядок `items`"
+ (" — на стройке он значит зависимость"
if stage_name == BUILD else " — на доработке он значит важность")
+ f", покажи карту человеку и только потом —"
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`; из `tasks/items/x.md`
тот же файл — уже `../docs/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}") from e
root = Path(pl["target"])
if not dir_within_cwd(root):
raise Usage(f"target вне рабочего каталога: {root}")
project = conf.find_root() or Path.cwd().resolve()
stage_name = str(pl.get("stage", "")).strip().lower()
if stage_name not in STAGES:
raise Usage(f"в карте нет стадии («stage»: {', '.join(STAGES)}) —"
f" без неё непонятно, что значит порядок записей в ней")
lay = Layout(root, {STAGE_KEY: stage_name}, project, load_config(project))
sections = pl.get("sections_backlog") or uniq_sections(DEFAULT_SECTIONS[stage_name])
known_sections = {s.lower() for s in sections}
# --- проверки: все до первой записи ---
problems: list[str] = []
if lay.index("backlog").exists():
problems.append(f"{lay.index('backlog')} уже есть — адаптация не поверх живого"
f" каталога; выбери пустой target")
if stage_name == BUILD and len(sections) > 1:
problems.append(f"стадия «{BUILD}», а секций {len(sections)}"
f" ({', '.join(sections)}): беклог стройки — один список")
slugs: set[str] = set()
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 (e := bad_type(it.get("type") or None)):
problems.append(f"{slug}: {e}")
elif not it.get("type"):
problems.append(f"{slug}: тип не назван — заполни `type` в карте"
f" ({', '.join(TAKEABLE)}). Машина его не угадывает:"
f" от типа зависит, каких разделов запись требует")
for e in (bad_why(it.get("why")), 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()
# Версия та же, что у `init`: каталог выводится из чужой раскладки сегодня и
# сегодняшним форматом, сколько бы лет ни было тому, из чего он выведен.
# Имён частей здесь нет — адаптация раскладывает всё по умолчаниям.
skeleton = init_files(lay, sections, stage_name)
for path, text in skeleton.items():
wr.file(path, text)
backlog_lines = skeleton[lay.index("backlog")].splitlines()
renames: dict[str, str] = {}
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
rtype = (it.get("type") or "").lower()
title = h1_of(rtype, it["title"])
tags = list(it.get("tags", []))
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(rtype, it["section"].lower(), it.get("reason", ""),
it.get("why", ""), 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("why", "")))
if pl.get("rejected"):
head = skeleton[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")
# Через `index`, а не `file`: отбивка секций живёт на записи индекса, и
# каталог, собранный в обход неё, встречал бы человека ошибкой `check`
# на первом же прогоне. По той же причине здесь же место сырья: карта могла
# положить разведку без «Вопроса» в середину списка, и `check --fix` потом
# переставил бы её — то есть тронул бы порядок, который человек подтвердил.
raw_now = {f"{it.get('slug') or it['old_slug']}.md" for it in pl.get("items", [])
if (it.get("type") or "").lower() == RESEARCH
and not re.search(rf"^##\s+{re.escape(lay.cfg['question_heading'])}\s*$",
it.get("body", ""), flags=re.M | re.I)}
wr.index(lay, "backlog", raw_last(backlog_lines, raw_now))
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()
# Настройки — тем же проходом, что и у `init`, и по той же причине: каталог,
# собранный здесь, обязан быть назван в `.av-dev.toml`, иначе следующая же
# команда не найдёт его и уйдёт искать умолчание. Стадия там же и по той же
# причине: без неё `check` откажет на первом же прогоне.
said = write_config(lay.project, {**adopt_cfg(lay), STAGE_KEY: stage_name})
# --- перекрёстные ссылки: тем же проходом, иначе они останутся битыми ---
ref_paths: list[Path] = [*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}, стадия {STAGE_RU[stage_name]}")
for line in said:
print(f" {line}")
print(f" задач {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)
unfit = [n for n, t in written.items()
if t["type"] in TAKEABLE and schema_verdict(lay, t)[0]]
print("\nпереходное состояние — назови его в докладе целиком:")
print(f" задач, не собравших разделы своего типа: {len(unfit)} —"
f" check это ошибкой не считает, но `ready` их не пропустит:"
f" брать сегодня физически нечего")
print(" закрывается порциями груминга по 5–8 задач (скилл groom):"
" превратить «готово, когда» в критерии с оракулами, вынуть вопросы"
" из прозы в раздел. Готовность к первой задаче — не «check зелёный»,"
" а «`ready` пропускает хотя бы верхние строки очереди»: берут по"
" одной, и годной обязана быть та, которую берут.")
print(" порядок строк проверь глазами: "
+ ("на стройке он значит зависимость, и выведен он из нумерации"
" источника — там, где её не было, порядок случаен"
if stage_name == BUILD else
"на доработке он значит важность, и машина её не знает: очередь"
" расставляется первым же грумингом"))
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", help="категория беклога")
p.add_argument("--type", choices=TYPES)
p.add_argument("--tag", help="тег или список через запятую (нужны ВСЕ): question")
p.add_argument("--raw", action="store_true",
help=f"только сырьё: {RESEARCH} без раздела «Вопрос»")
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, required=True,
help="тип решает схему записи: разделы и право на взятие")
p.add_argument("--section", help="категория беклога")
p.add_argument("--why")
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("--why")
p.add_argument("--type", choices=TYPES)
p.add_argument("--add-tag", dest="add_tag")
p.add_argument("--rm-tag", dest="rm_tag")
p.add_argument("--dir")
p = sub.add_parser("move", help="переставить строку: место в списке или другая секция")
p.add_argument("slug")
p.add_argument("--section", help="другая категория беклога;"
" без него — текущая секция записи")
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("ready", help="схема типа выполнена — запись можно брать в работу")
p.add_argument("slugs", nargs="+")
p.add_argument("--dir")
p = sub.add_parser("init", help="завести каталог задач в новом проекте")
p.add_argument("--dir")
# Стадия обязательна: умолчания у неё нет и быть не может. Подставленное
# значение отвечало бы за человека на единственный вопрос, который тут
# вообще задаётся, — строится приложение или живёт.
p.add_argument("--stage", choices=STAGES, required=True,
help="build — беклог это план стройки (порядок = зависимость);"
" support — очередь правок (порядок = важность)")
p.add_argument("--sections", help="секции беклога; на стройке ровно одна")
p.add_argument("--items")
p.add_argument("--backlog")
p.add_argument("--rejected")
p = sub.add_parser("stage", help="показать или сменить стадию проекта")
p.add_argument("to", nargs="?", choices=STAGES,
help="без него — показать текущую")
p.add_argument("--sections", help="секции беклога новой стадии")
p.add_argument("--dir")
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("--stage", choices=STAGES, required=True)
s.add_argument("--target", default="tasks")
s.add_argument("--out", default="tasks-adopt-plan.json")
s.add_argument("--sections", help="секции беклога; на стройке ровно одна")
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 "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)
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),
"ready": lambda: cmd_ready(lay, a),
"stage": lambda: cmd_stage(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 — последний рубеж, код 4 по словарю
print(f"внутренний сбой ({type(e).__name__}): {e}", file=sys.stderr)
sys.exit(EXIT_INTERNAL)