From a22a825c40c90338cdd5188b272e8e44c0562ecf Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Fri, 24 Jul 2026 08:45:32 +0300 Subject: [PATCH] =?UTF-8?q?init:=20=D0=BC=D0=B0=D1=80=D0=BA=D0=B5=D1=82?= =?UTF-8?q?=D0=BF=D0=BB=D0=B5=D0=B9=D1=81=20av-dev-skills=20+=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=B3=D0=B8=D0=BD=20backlog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отдельный репозиторий-маркетплейс для личных плагинов и скилов разработки, чтобы подключать их к проектам через `/plugin`, а не держать в глобальном `~/.claude/skills`. Первый плагин — backlog: перенесён скилл ведения беклога из `~/.claude/skills/backlog` под именем `av-dev-backlog`. Путь к `backlog.py` переведён с фиксированного `~/.claude/...` на `$CLAUDE_PLUGIN_ROOT`, чтобы работать после установки плагина. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude-plugin/marketplace.json | 15 + .gitignore | 2 + README.md | 29 + backlog/.claude-plugin/plugin.json | 9 + backlog/skills/av-dev-backlog/SKILL.md | 168 +++++ .../av-dev-backlog/references/from-review.md | 84 +++ .../av-dev-backlog/references/grooming.md | 101 +++ .../skills/av-dev-backlog/references/split.md | 62 ++ .../av-dev-backlog/references/task-format.md | 104 +++ .../skills/av-dev-backlog/scripts/backlog.py | 706 ++++++++++++++++++ 10 files changed, 1280 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backlog/.claude-plugin/plugin.json create mode 100644 backlog/skills/av-dev-backlog/SKILL.md create mode 100644 backlog/skills/av-dev-backlog/references/from-review.md create mode 100644 backlog/skills/av-dev-backlog/references/grooming.md create mode 100644 backlog/skills/av-dev-backlog/references/split.md create mode 100644 backlog/skills/av-dev-backlog/references/task-format.md create mode 100755 backlog/skills/av-dev-backlog/scripts/backlog.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..15fb345 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "av-dev-skills", + "owner": { + "name": "Anton Vakhrushev", + "email": "anwinged@gmail.com" + }, + "plugins": [ + { + "name": "backlog", + "source": "./backlog", + "description": "Ведение беклога задач как каталога markdown-файлов: заведение из диалога, разбор находок ревью, груминг, приоритизация, декомпозиция, штурм идей.", + "version": "0.1.0" + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..77aad1f --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# av-dev-skills + +Личный маркетплейс плагинов и скилов для разработки — чтобы подключать их к +проектам по мере необходимости, а не держать в глобальном `~/.claude`. + +## Подключение + +``` +/plugin marketplace add /home/av/projects/private/dev-skills +/plugin install backlog@av-dev-skills +``` + +Маркетплейс можно добавить и по git-URL, если репозиторий будет опубликован. + +## Плагины + +- **backlog** — ведение беклога задач как каталога markdown-файлов (одна задача = + один файл `.md` + строка в индексе `README.md`). Скилл + `av-dev-backlog`: заведение задачи из диалога, разбор находок аудита/ревью, + груминг, приоритизация, декомпозиция, штурм идей. Реализацией не занимается. + Вызов: `/backlog:av-dev-backlog`. + +## Структура + +``` +.claude-plugin/marketplace.json — манифест маркетплейса +/.claude-plugin/plugin.json — манифест плагина +/skills//SKILL.md — скилы плагина (авто-обнаружение) +``` diff --git a/backlog/.claude-plugin/plugin.json b/backlog/.claude-plugin/plugin.json new file mode 100644 index 0000000..8897c0e --- /dev/null +++ b/backlog/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "backlog", + "description": "Ведение беклога задач как каталога markdown-файлов (одна задача = один файл + строка в индексе README). Заведение, груминг, приоритизация, декомпозиция, штурм идей, разбор находок ревью.", + "version": "0.1.0", + "author": { + "name": "Anton Vakhrushev", + "email": "anwinged@gmail.com" + } +} diff --git a/backlog/skills/av-dev-backlog/SKILL.md b/backlog/skills/av-dev-backlog/SKILL.md new file mode 100644 index 0000000..7c61c53 --- /dev/null +++ b/backlog/skills/av-dev-backlog/SKILL.md @@ -0,0 +1,168 @@ +--- +name: av-dev-backlog +description: Работа с беклогом задач как с каталогом markdown-файлов (одна задача = один файл + строка в индексе README). Заведение задачи из диалога, разбор находок аудита/ревью в задачи, груминг (интерактивная чистка неактуального), приоритизация, декомпозиция на независимо полезные части, мозговой штурм идеи. Использовать, когда просят добавить задачу/идею в беклог, превратить находки ревью в задачи, разобрать беклог, расставить приоритеты, разбить задачу или проработать идею. Не реализует задачи — этим занимается пайплайн задачи проекта. +--- + +# Беклог + +Беклог — каталог markdown-файлов: одна задача = один файл `.md`, плюс +строка в индексе `README.md`. Скилл ведёт беклог: заводит, чистит, приоритизирует, +дробит, штурмует идеи. **Реализацией не занимается** — это дело пайплайна задачи. + +## Три правила, из которых всё следует + +Ситуация не покрыта инструкцией — решай по ним. + +1. **Беклог гниёт с той стороны, где его пополняют.** Заведение — самая частая + операция и с худшим отказом: из одного разговора рождается пять файлов, и + груминг потом разгребает то, чего не надо было заводить. Дедупликация и фильтр + на входе дешевле любой чистки. Заводим только то, что **не делаем сейчас** и о + потере чего пожалеем. +2. **Файл — источник истины, индекс производен.** Разошлись — неправ индекс. + Согласованность механизируема и проверяется командой, а не вниманием: всё, что + ловит `backlog.py check`, не должно попадать ни в чек-лист, ни в промпт. +3. **Причина переживает запись.** Приоритет без причины будет переспорен на + следующем груминге; выкинутая без причины задача вернётся через квартал тем же + текстом. Реализованная задача оставляет след в коммите и спеке — выкинутая не + оставляет ничего, поэтому у неё есть кладбище. + +## Инструмент (`backlog.py`) + +Пусть `bl="$CLAUDE_PLUGIN_ROOT/skills/av-dev-backlog/scripts/backlog.py"`. + +``` +python3 $bl check # согласованность + метрики здоровья, exit 1 при расхождениях +python3 $bl check --fix # + починить безопасный дрейф (секция, заголовок, дубли) +python3 $bl list --stale # от самой залежавшейся; ещё --priority --type --tag +python3 $bl add --slug S --title T --priority P [--type idea|epic] [--hook H] [--reason R] [--tag a,b] +python3 $bl edit S [--title T] [--hook H] [--type idea|epic|task] # переименовать / сменить хук, тип +python3 $bl move S --priority P [--reason R] # перенести в другую секцию +python3 $bl close S --reason R # на кладбище + удалить (выкинута) +python3 $bl close S --implemented # просто удалить (реализована, есть коммит) +python3 $bl init [--sections "..."] # завести беклог в новом проекте +``` + +Тип задачи — английское ключевое слово `idea` / `epic` / `task` (как и прочие +токены команд); `task` префикса не несёт, `idea`/`epic` кодируются `[idea]`/ +`[epic]` в заголовке. Текст задачи при этом русский. + +**Мутации правят файл и индекс заодно** — руками строку индекса или мета-строку +не пиши, зови `add`/`edit`/`move`/`close`. Смена заголовка, хука или типа (в том +числе понижение задачи до `[idea]`) — это `edit`, а не ручная правка H1 и +индекса: `edit` держит их в синхроне. Механика (слаг в имени, секция по +приоритету, формат кладбища, экранирование ввода) не может рассогласоваться, +потому что её делает скрипт. Тело задачи скрипт не трогает — `add` кладёт +заголовок, мета-строку и плейсхолдер, а контекст, шаги и ссылки ты дописываешь +редактором (пока плейсхолдер на месте, `check` напоминает, что тело не дописано). + +`check` — единственный судья согласованности; что именно он ловит, скажет его +вывод, здесь не пересказываем. Гоняй его **в начале сессии** и **после каждой +правки**, даже если правил мутациями: дрейф мог накопиться раньше. Накопившийся +дрейф чини `check --fix` — он детерминированно правит безопасное (секция по файлу, +заголовок из H1, дубли строк), а неоднозначное (ссылка на исчезнувший файл, +битые строки) выносит тебе. Это идёт строкой доклада. + +Формат файла, мета-строки, слага, индекса и кладбища — +[references/task-format.md](references/task-format.md). Там же тест «готова к +взятию». + +## Сценарии + +### Завести задачу или идею из диалога + +1. **Фильтр.** Делаем прямо сейчас — не заводим. Не пожалеем о потере — не + заводим. Родилось три кандидата — покажи их и спроси, какие заводить: молча + заведённая пачка и есть тот самый отказ из правила 1. +2. **Дедуп.** `list` плюс поиск по слагам, хукам и телам (`grep -ril`), **включая + `CLOSED.md`**. Нашлось в беклоге — **дописываем в существующий файл**, а не + заводим соседний. Нашлось на кладбище — покажи пользователю ту строку и что + изменилось с момента отказа: та же идея вернулась через диалог, а не через + ревью. Две задачи об одном — самая дорогая находка груминга. +3. **Тип по тесту готовности** (см. task-format): проходит — задача (`--type task`, + без префикса), не проходит — идея (`--type idea`), проходит по пользе, но не + делается одним заходом — эпик (`--type epic`, сперва декомпозиция). +4. `add --slug … --title … --priority … --hook …` (тип, причину, теги — по + месту). Хук отвечает «почему это в беклоге», а не пересказывает первый абзац. + Затем допиши тело файла. +5. `check`. + +### Разобрать находки аудита или ревью + +Ревью и аудиты — код-ревью, архитектурный проход, аудит безопасности — тоже +источник задач, но с зеркальной диалогу опасностью: не пять файлов из одной +мысли, а сорок файлов из сорока сырых находок. Защита та же, что в самом ревью: +кластеризация по причине, дедуп против беклога, находка без свидетельства → идея, +а не задача, и карта кластеров пользователю до создания файлов. Порядок и +отображение серьёзности — [references/from-review.md](references/from-review.md). + +### Груминг + +Интерактивная сессия порциями, по дате правки из git и с правилом остановки — +[references/grooming.md](references/grooming.md). Ключевое: перед вопросом +пользователю проверь по коду и спекам, не сделано ли уже попутно, — это самая +частая находка и она не требует ничьего решения. + +### Приоритизация + +Приоритет — грубая оценка «ценность / стоимость», не обязательство к порядку. +Никаких очков и часов: уровни те, что есть в секциях индекса. + +- Меняешь уровень — `move --priority <новый> --reason <причина>`; причина + уезжает в мета-строку. +- Повышаешь — назови, **что именно эта задача обгоняет**. Повышение без + проигравшего это не приоритизация, а согласие с последним, кто говорил. +- Задача, давно лежащая в нижней секции и не двигавшаяся (по дате git), — + кандидат на кладбище, а не на новый круг «оставить как есть». + +### Декомпозиция и штурм идеи + +[references/split.md](references/split.md). Обе операции превращают одну запись в +несколько, и у обеих есть проверяемый тест: части должны **мерджиться порознь** и +**каждая давать видимую пользу**, а у штурма исход «выкинуть» — полноправный. + +## Общее для всех сценариев + +- **Кладбище.** Задача уходит из беклога без реализации → `close --reason + <причина>`: скрипт пишет строку в `CLOSED.md` (дата, слаг, заголовок, причина, + бывший приоритет) и удаляет файл со строкой индекса. Реализованные туда не идут + — у них есть коммит, спека и ADR; для них `close --implemented`. +- **Границы покрытия в отчёте.** Любая сессия груминга, приоритизации или штурма + заканчивается строкой «просмотрено N из M, не трогали — …». Отчёт без неё + сообщает «беклог разобран», не сообщая, какая его часть осталась нетронутой. +- **Развилки — пользователю.** Через `AskUserQuestion`, с уже сформулированным + предварительным суждением (рекомендация — первым вариантом). Что выкинуть, что + повысить, какая рамка идеи верна — решение пользователя. Слаг, формулировка, + порядок строк в индексе — механика, делаем сами. +- **Не больше трёх вопросов за раз.** Пачка длиннее трёх тяжела для ответа; + решений больше — веди **несколько итераций** диалога по ≤3, а не один + перегруженный запрос. Между итерациями применяй уже решённое. +- **Ничего не удаляем молча.** Файл задачи исчезает только через `close` — + `--reason` (выкинута) или `--implemented` (реализована). Прямого `rm` нет. + +## Переносимость + +Скилл независим от **языка программирования, сборки, CI и трекера**: он ничего не +знает ни про Go, ни про npm, ни про конкретный багтрекер — беклог для него просто +каталог markdown. Текст задач — русский (язык документации проекта); зашита только +латиница слага. + +- **Каталог беклога**: аргумент → указатель в `CLAUDE.md` проекта → поиск + (`docs/backlog`, `backlog`, `doc/backlog`, `docs/tasks`). Не нашёлся — это новый + проект: `init` заводит индекс и кладбище (секции по умолчанию высокий/средний/ + низкий, `--sections` переопределяет). +- **Слаг** — латиница kebab-case всегда; заголовок, тело, хук — по-русски. +- **Уровни приоритета** берутся из заголовков секций индекса как есть, их + количество и названия — дело проекта. +- **Имена служебных файлов** (`README.md` — индекс, `CLOSED.md` — кладбище) + фиксированы скиллом, не проектом. + +Проектные тонкости (куда переезжает суть реализованной задачи, кто удаляет файл, +как беклог связан с трекером-инбоксом) описаны в `CLAUDE.md` проекта — прочитай +его перед работой. + +## Чего этот скилл не делает + +Не пишет код, не заводит спеки и change, не берёт задачу в работу — этим +занимается пайплайн задачи проекта, а этот скилл владеет только форматом и +содержимым беклога. Не решает за пользователя, что важно. Не переоформляет +существующие задачи «заодно»: правится то, чего касается операция. diff --git a/backlog/skills/av-dev-backlog/references/from-review.md b/backlog/skills/av-dev-backlog/references/from-review.md new file mode 100644 index 0000000..8bc0f22 --- /dev/null +++ b/backlog/skills/av-dev-backlog/references/from-review.md @@ -0,0 +1,84 @@ +# Задачи из аудита и ревью + +Ревью и аудиты — код-ревью, архитектурный проход, аудит безопасности, любой +разбор другим агентом — порождают находки, часть которых становится задачами +беклога. Это отдельный интейк со своей опасностью, **зеркальной** интейку из +диалога. + +- Интейк из диалога грешит переполнением: из одной мысли рождается пять файлов. +- Интейк из ревью грешит сваливанием: сорок сырых находок превращаются в сорок + файлов. Беклог раздувается, а следующий груминг склеивает их обратно. + +Защита от сваливания — та же, что в самом ревью: **кластеризация по причине, а не +файл-на-находку.** Если у ревью был триаж — половина работы уже сделана, бери его +выход. Если нет — триажируй сам, прежде чем заводить. + +## Находка агента — не задача + +Мнение агента — **гипотеза, пока у неё нет свидетельства** (падающий тест, +воспроизводимый шаг, положение гайда). Согласие нескольких находок само по себе +достоверность не повышает: это один источник, высказавшийся несколько раз. + +Отсюда фильтр входа, поверх обычного «не делаем сейчас + пожалеем о потере»: + +- **Находка со свидетельством**, отложенная к исполнению → **задача**. + Свидетельство и последствие переносим в тело — это её «почему», то самое, что + переживает запись. +- **Находка без свидетельства / низкой уверенности** → **идея** (`[idea]`), а не + задача. Она не заработала приоритизацию: сравнивать неподтверждённое не с чем. + Её судьба — штурм, где либо найдётся подтверждение, либо она уедет на кладбище. +- **Уже починено по ходу ревью** → **ничего**. Починенное не заводим. +- **Развилка, решённая при ревью** → ничего; решённая «потом» → задача с + зафиксированным вопросом. + +## Порядок + +1. **Возьми выход триажа, а не сырые находки.** Сырой отчёт — это симптомы до + дедупликации; в нём одна причина размазана по нескольким строкам. +2. **Кластеризуй по причине.** Пять находок об одном отсутствующем инварианте — + одна задача, а не пять. Класс мелочи (nits, косметика) — **один пакетный файл** + со списком пунктов, а не файл на каждую запятую. +3. **Дедуп против беклога и кладбища.** Аудит переоткрывает уже заведённое и уже + выкинутое. Нашлось в беклоге — дописываем находку в существующий файл. Нашлось + на кладбище — это сигнал: причина отказа могла устареть, выноси пользователю, а + не заводи молча заново. +4. **Покажи карту до создания файлов.** Кластер → задача / идея / строка в + пакетный файл / уже в беклоге / отброшено — пачкой через `AskUserQuestion`. + Это тот же барьер, что и «три кандидата» в интейке из диалога: массовое + заведение файлов без подтверждения — ровно тот отказ, ради которого интейк из + ревью и выделен. Дешёвая мелочь по явному согласию может заводиться и без + поштучного вопроса — но карта пользователю всё равно предъявляется. +5. **Заводи утверждённое** через `backlog.py add`, с двумя добавками: + - **тег партии** — `add … --tag review-ГГГГ-ММ-ДД` (или `audit-<тема>`), чтобы + весь заход груминга поднимался одной командой `backlog.py list --tag …`; + - **провенанс в теле** — кто нашёл, каким проходом, с каким свидетельством. Без + него через месяц не отличить проверенную находку от догадки. +6. `backlog.py check`. + +## Отображение серьёзности на приоритет + +Правило концептуальное, от полей конкретного отчёта не зависит: + +- **выше серьёзность → выше приоритет.** Самый тяжёлый класс находок → верхняя + секция индекса, следующий → следующая. Отображать словарь серьёзности отчёта на + словарь приоритетов проекта точно нечем — при сомнении спрашивай пользователя. +- **низкая уверенность или нет свидетельства → идея**, не задача. +- **мелочь → строка в пакетный файл**, не отдельный. +- **уже починено / развилка решена сейчас → ничего.** + +Если у ревью структурированный отчёт с полями серьёзности, уверенности, +свидетельства и предписанного действия (например, конвейер ревью jellybit даёт +`Severity`/`Confidence`/`Оракул`/`Действие: инлайн|развилка`) — правило выше +ложится на эти поля механически. Но это пример одного формата, а не требование к +источнику: тот же фильтр применяется к находкам в свободной форме. + +Границы покрытия отчёта — то, что ревью проверить **не смогло**, — не находки и в +задачи не идут: у них нет предмета. Их место — в докладе, не в беклоге. + +## Доклад + +- Источник (какое ревью/аудит, сколько находок на входе). +- Свёрнуто в задачи: N кластеров из M находок, со слагами и тегом партии. +- Что не заведено и почему: починено инлайн, уже в беклоге, ушло в идеи, на + кладбище. +- `backlog.py check`. diff --git a/backlog/skills/av-dev-backlog/references/grooming.md b/backlog/skills/av-dev-backlog/references/grooming.md new file mode 100644 index 0000000..f4b1df0 --- /dev/null +++ b/backlog/skills/av-dev-backlog/references/grooming.md @@ -0,0 +1,101 @@ +# Груминг беклога + +Цель — выкинуть то, что перестало быть задачей, и вернуть остальному честное +состояние. Не «пересмотреть всё», а «пересмотреть порцию до конца». + +## Порция и правило остановки + +Тридцать задач за один заход — это усталость и штамповка: последние десять +получат «оставить» не потому, что живы, а потому, что сессия затянулась. + +- **5–8 задач за сессию.** Больше — только если пользователь настаивает, и тогда + разбей на явные порции с промежуточным докладом. +- **Отбор порции** — один из: + - `backlog.py list --stale` — самые залежавшиеся по дате последней правки в + git; поле «дата касания» заводить не надо, git её уже хранит; + - одна секция приоритета целиком; + - один тег (`--tag`) — например, задачи, пришедшие из одного ревью; + - список от пользователя. +- **Останавливайся на границе порции**, даже если «ещё чуть-чуть осталось». + +## Что делать с каждой задачей + +Сперва то, что не требует ничьего решения: + +1. **Проверь, не сделано ли уже.** Задача, реализованная попутно в соседнем + изменении, — самая частая находка груминга. Смотри код, спеки, историю + коммитов по ключевым словам задачи. Удаление задачи «как реализованной» — + деструктивно и без следа (кладбище для реализованных не пишется), поэтому + порог улики жёсткий: удаляем (`close --implemented`), только имея + **конкретный коммит или строку спеки**, закрывающие задачу, и ссылка на них + идёт в доклад. Есть лишь косвенные признаки — не удаляй сам, вынеси в пачку + вопросов. Сделана частично → задача сжимается до остатка: тело правишь + редактором, заголовок и хук — через `edit --title … --hook …`. +2. **Проверь, не отменена ли решением.** ADR, спека или архивный change мог + закрыть вопрос иначе. Тогда `close --reason "<ссылка на решение>"`. +3. **Проверь пересечения внутри порции.** Две задачи об одном — содержимое в + одну, вторую `close --reason "слита с <другой-slug>"`. + +Затем — то, что решает пользователь: + +4. **Жива ли она вообще.** Контекст мог измениться: ушла зависимость, отпал + сценарий, обошли иначе. Здесь и звучит вопрос о выкидывании. +5. **Тот ли приоритет** (тест и правила — в SKILL.md и task-format.md). +6. **Задача ли это по-прежнему.** Не проходит тест «готова к взятию» → `edit + --type idea`, и её дальнейшая судьба — штурм, а не приоритизация. + Разрослась → `edit --type epic`, дальше декомпозиция. + +## Храповик + +Сильно залежавшаяся задача — сигнал сама по себе: её либо ни разу не собирались +делать, либо нечем взять. Измеряй наблюдаемым — датой последней правки из git +(`backlog.py list --stale` ставит такие первыми); счётчик «сколько грумингов +пережила» нигде не хранится, поэтому на него не опирайся. + +Задача из верхних строк `--stale`, которую и этот заход оставляет без изменений, +**либо двигается (вверх или на кладбище), либо остаётся с явно записанной +причиной**, почему её держим (`move --priority <тот же> --reason …`). +Молчаливое «оставить как есть» на давно неподвижной задаче — это решение не +принимать решение; запись причины превращает его в осознанное и не даёт тому же +вопросу всплыть на следующем груминге. В примере ниже вариант «оставить» именно +такой — с названной причиной, а не по умолчанию. + +## Интерактив + +- Вопросы — через `AskUserQuestion`, **не больше трёх за раз**. Порция в 5–8 + задач обычно даёт больше трёх суждений — тогда веди несколько итераций по ≤3, а + не по одному на задачу и не одним перегруженным запросом. +- К каждому варианту — **предварительное суждение**, рекомендация первым + вариантом: «предлагаю выкинуть, потому что …». Пользователю дешевле возразить, + чем судить с нуля. +- Всё, что решается фактом (сделано / отменено / дублируется), решай сам и + показывай списком в докладе, а не выноси в вопросы. + +Пример одной итерации — три залежавшихся задачи, механику по ним уже разобрали: + +> **Груминг: 3 залежавшихся (порция по `--stale`)** +> +> 1. `versii-kachestvo-repaki` — репаки, апгрейд 1080p→2160p +> - Выкинуть на кладбище *(рекомендую)* — помечена «не боль», за полгода ни разу не возникла +> - Оставить в низком +> - Поднять в средний +> 2. `backup-sqlite` — бэкап SQLite +> - Оставить в среднем *(рекомендую)* — не сработала, но риск реальный +> - Поднять в высокий — обгоняет `retention-ochistka-bd`: без бэкапа ретеншн опасен +> - Выкинуть +> 3. `guessit-sputnik` — guessit как сервис-спутник +> - Понизить до `[idea]` *(рекомендую)* — не проходит тест «готова к взятию» +> - Оставить задачей в низком + +Каждый вариант несёт причину — ту самую, что уедет в `move --reason` или +`close --reason`. Ответы применяй сразу и, если в порции осталось ещё, следующей +итерацией показывай следующие ≤3. + +## Доклад + +- Что просмотрено: N из M, по какому признаку отобрана порция. +- Изменения списком: удалено (реализовано), на кладбище (с причинами), понижено + до идей, слито, переприоритизировано. +- **Границы покрытия**: сколько задач не трогали и какие именно секции или теги + остались — иначе доклад читается как «беклог разобран». +- `backlog.py check` после правок; результат — строкой в докладе. diff --git a/backlog/skills/av-dev-backlog/references/split.md b/backlog/skills/av-dev-backlog/references/split.md new file mode 100644 index 0000000..6ed4785 --- /dev/null +++ b/backlog/skills/av-dev-backlog/references/split.md @@ -0,0 +1,62 @@ +# Декомпозиция и мозговой штурм + +Обе операции превращают одну запись беклога в несколько (или в ноль). Разница в +входе: декомпозиция дробит **готовую задачу**, штурм прорабатывает **идею**, +которая ещё не задача. + +## Тест декомпозиции + +Задачу можно дробить, только если части удовлетворяют **обоим** условиям: + +1. **Мерджатся независимо.** Часть Б не требует, чтобы часть А была уже влита. + Есть порядок «сперва А, потом Б, иначе не собрать» → это не декомпозиция, а + план реализации: шаги остаются **внутри одного файла** в разделе «Шаги». +2. **Каждая даёт видимую пользу.** Часть, полезная только в комплекте с другой, — + не самостоятельная задача. Пользу проверяй тем же тестом «готова к взятию» + (task-format): что станет наблюдаемо иначе именно от этой части. + +Не проходит хотя бы одно — **не дроби**. Ложная декомпозиция плодит файлы, +которые нельзя взять поодиночке, и груминг потом их склеивает обратно. + +## Что делать с родителем + +После разделения родитель **не остаётся** третьей висящей строкой: + +- части полностью замещают его → `close --reason "разложена на a, b"`. + Кладбище здесь — не «выкинули», а именно тот след, что переживает запись: + через квартал вопрос «куда делась задача X» отвечается строкой кладбища со + ссылками на наследников, а не археологией git; +- родитель осмыслен как зонтик → `edit --type epic`, тело — ссылки на + задачи-части, своих шагов у него нет. + +Одно и то же не должно лежать и в родителе, и в части. Задвоение — то же +расхождение, что ловит `check`, только внутри тел. + +## Мозговой штурм идеи + +Идея (`[idea]`) не проходит тест «готова к взятию»: неясно, что именно делаем. +Штурм проясняет — и это **generative-операция, а не applicative**. + +Applicative-штурм («перечисли задачи, следующие из идеи») выдаёт очевидное: +перечисляется то, что уже видно в формулировке. Ценное — на уровень выше. + +1. **Сперва — формы, а не задачи.** Предложи **три разные постановки** идеи и + назови **компромисс каждой**: что она даёт, чем платит, что оставляет за + бортом. Если получилась одна постановка — штурм не состоялся, это applicative. +2. **Вынеси формы пользователю** через `AskUserQuestion` с компромиссами. Рамку + выбирает он: это продуктовое решение, не механика. +3. **Только выбранную форму** дроби по тесту декомпозиции выше. + +**«Выкинуть» — полноправный исход штурма, а не его неудача.** Проработка, честно +показавшая, что пользы нет или она несоразмерна цене, — это результат: идея +уезжает на кладбище с этой самой причиной, и та причина гасит её повторное +появление. + +## Доклад + +- Идея/задача на входе, выбранная рамка (для штурма), задачи-наследники со + слагами и приоритетами. +- Судьба родителя: удалён / стал эпиком / выкинут. +- `backlog.py check` после правок. +- Границы покрытия: какие постановки рассмотрены и какие сознательно отброшены — + чтобы штурм не пришлось повторять с нуля. diff --git a/backlog/skills/av-dev-backlog/references/task-format.md b/backlog/skills/av-dev-backlog/references/task-format.md new file mode 100644 index 0000000..bcd13ab --- /dev/null +++ b/backlog/skills/av-dev-backlog/references/task-format.md @@ -0,0 +1,104 @@ +# Формат беклога + +Заголовок, мета-строку и строку индекса ставит `backlog.py add` — руками их не +пишут. Этот файл описывает, что именно скрипт создаёт и что проверяет `check`; +тело задачи (контекст, шаги, ссылки) дописывает агент. + +## Файл задачи + +`.md` в каталоге беклога: + +```markdown +# Раздачи с докачиванием (merge при повторном добавлении) + +**Приоритет:** высокий — блокирует типовой сценарий свежих сериалов · **Теги:** layout, ingest + +Свежий сериал раздают по мере выхода: торрент с 5 из 10 эпизодов позже +перезаливают целиком, пользователь добавляет раздачу повторно. … + +Шаги: +- в плане раскладки отличать «путь занят живой ссылкой того же матча» от коллизии +- merge-раскладка: существующее пропустить, недостающее доложить + +Зависит от правила сходимости. Связано: drafts/logical-title-model.md §6.2. +``` + +- **Заголовок H1** — он же заголовок строки в индексе, дословно. Тип кодируется + префиксом `[idea]` / `[epic]`; обычная задача без префикса. Отдельного поля + типа **нет**: два места для одного факта разъезжаются, а префикс виден прямо в + индексе, где и принимается решение «брать или не брать». +- **Мета-строка** — первая непустая строка после заголовка. Обязателен приоритет, + причина после тире желательна, теги опциональны. Поля разделяются ` · `, их + порядок свободный. `·` — служебный разделитель: в тексте причины его быть не + должно, иначе причина обрежется по нему. +- **Тело** — контекст (почему это вообще задача), принятые решения, шаги, + ссылки на спеки, ADR, черновики, прошлые ревью. Пишется на языке документации + проекта. + +Тело — не план реализации и не спецификация: принятое и реализованное переезжает +в документацию проекта, а файл задачи удаляется. + +## Слаг + +Латиница и цифры, kebab-case, без ведущих, хвостовых и двойных дефисов +(`foo-bar`, не `-foo`, `a--b`). Именуется **по сути задачи, а не по текущей +формулировке**: заголовок будет переписан на груминге, а слаг стоит в ссылках из +других задач, коммитов и черновиков. Транслит русского названия допустим, если +суть иначе не выражается коротко. + +## Индекс + +`README.md` в том же каталоге: преамбула, затем секции по приоритетам, в каждой — +строки вида + +```markdown +- [Заголовок задачи дословно](slug.md) — хук +``` + +Хук отвечает на «почему это лежит в беклоге» одним предложением: состояние, +остаток, боль. Пересказ первого абзаца бесполезен — он уже есть по ссылке. + +Порядок секций задаёт порядок приоритетов, их названия — единственный словарь +уровней. Внутри секции порядок значения не имеет. Секции приоритетов — **единственные +заголовки `##` в индексе**: любой другой `##` в преамбуле проверка сочтёт уровнем +приоритета. + +Индекс **производен**: расходится с файлом — правим индекс. Строку индекса руками +не пишут — её ставит `backlog.py add` в секцию приоритета и двигает `move`. + +## Кладбище — `CLOSED.md` + +Туда уходит задача, покинувшая беклог **без реализации**. Строку пишет +`backlog.py close --reason`, а `check` следит за её форматом: + +```markdown +- 2026-07-23 `versii-kachestvo-repaki` — Версии/качество одного тайтла (репаки, + апгрейд 1080p → 2160p). Причина: калибровка болей — не боль, ни разу не + возникло за полгода. Был приоритет: низкий. +``` + +Реализованные сюда не попадают: у них остаётся коммит, спека, ADR. У выкинутой не +остаётся ничего — и через квартал она возвращается тем же текстом через инбокс. +Кладбище — первое место, куда смотрит дедупликация при заведении. + +Запись на кладбище не запрещает завести задачу заново: изменился контекст — +заводим и ссылаемся на строку кладбища, объясняя, что изменилось. + +## Тест «готова к взятию» + +Задача готова, если из файла отвечаются три вопроса: + +1. **Что станет наблюдаемо иначе**, когда она сделана — снаружи: пользователю, + владельцу сервиса или разработчику. «Отрефакторить X» — не ответ; «перестанет + ломаться Y при Z» — ответ. +2. **По чему видно, что закончено.** Признак завершённости, а не список работ. +3. **Почему приоритет такой** — одна строка. + +Не отвечается первый или второй вопрос → это **идея**, её место в штурме, а не в +приоритизации. Приоритизировать идеи бессмысленно: сравнивается неизвестно что. + +Отвечается всё, но задача не делается одним заходом и не мерджится целиком → +**эпик**, сперва декомпозиция. + +Тест применяется при заведении и на груминге. К старым задачам, которых операция +не касается, задним числом не применяется — беклог не переоформляют «заодно». diff --git a/backlog/skills/av-dev-backlog/scripts/backlog.py b/backlog/skills/av-dev-backlog/scripts/backlog.py new file mode 100755 index 0000000..1c04547 --- /dev/null +++ b/backlog/skills/av-dev-backlog/scripts/backlog.py @@ -0,0 +1,706 @@ +#!/usr/bin/env python3 +"""Детерминированный инструмент беклога: файлы задач против индекса README. + +Согласованность беклога — механизируемая вещь, и держать её вниманием агента +дорого и ненадёжно. Скрипт не только проверяет, но и **пишет**: создание, +переименование, перенос между приоритетами и закрытие правят файл и индекс +заодно, так что рассогласовать их вручную нельзя. Всё, что здесь механизировано, +не должно попадать ни в промпт, ни в чек-лист человека. + +Источник истины — файл задачи. Индекс производен от файлов: расходятся — +неправ индекс. + +Тип задачи — ключевое слово (idea | epic | task); по-английски, как и прочие +токены команд. Обычная задача (task) префикса не несёт, idea/epic кодируются +префиксом `[idea]`/`[epic]` в заголовке. Текст самой задачи — русский. + +Использование: + backlog.py check [--dir DIR] [--fix] согласованность (+ здоровье беклога); + --fix чинит безопасный дрейф + backlog.py list [--dir DIR] [фильтры] список задач + --stale от самой залежавшейся (дата последней правки из git) + --priority СЛОВО / --type idea|epic / --tag СЛОВО фильтры + backlog.py add --slug S --title T --priority P [--type idea|epic] + [--hook H] [--reason R] [--tag a,b] [--dir DIR] + создать задачу: файл + строка индекса + backlog.py edit S [--title T] [--hook H] [--type idea|epic|task] [--dir DIR] + сменить заголовок/хук/тип (файл + индекс) + backlog.py move S --priority P [--reason R] [--dir DIR] + перенести в другую секцию приоритета + backlog.py close S (--reason R | --implemented) [--dir DIR] + закрыть: --reason → кладбище + удаление, + --implemented → просто удаление (есть коммит) + backlog.py init [--dir DIR] [--sections "высокий,средний,низкий"] + завести пустой беклог в новом проекте + +Тело задачи (контекст, шаги, ссылки) остаётся агенту — add кладёт лишь заголовок, +мета-строку и плейсхолдер; агент дописывает тело редактором. + +Границы безопасности: слаг — только латиница kebab-case (traversal невозможен), +--dir обязан быть внутри рабочего каталога, в заголовок/хук/причину не пролезет +перевод строки, `·` в причине запрещён (это разделитель мета-полей). + +Язык не зашит инструментально: приоритеты сопоставляются с заголовками секций +индекса как есть. Текст задач — русский. +""" + +import argparse +import datetime +import os +import re +import subprocess +import sys +from pathlib import Path + +INDEX = "README.md" +CLOSED = "CLOSED.md" +SERVICE = {INDEX, CLOSED} + +META_FIELD = re.compile(r"^\*\*(.+?):\*\*\s*(.*)$") +INDEX_ENTRY = re.compile(r"^- \[(.+?)\]\((.+?\.md)\)\s*(?:—\s*(.*))?$") +SECTION = re.compile(r"^##\s+(.+?)\s*$") +TYPE_PREFIX = re.compile(r"^\[(.+?)\]\s*(.*)$") +SLUG_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") +SLUG = re.compile(SLUG_RE.pattern + r"\.md") +# Строка кладбища: - ГГГГ-ММ-ДД `slug` — текст +CLOSED_ENTRY = re.compile(r"^- \d{4}-\d{2}-\d{2} `[a-z0-9-]+` — .+") + +TYPES = ("idea", "epic") # непустые типы-ключевые слова, префикс [..] в H1 +PLAIN_TYPE = "task" # обычная задача — без префикса +STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check + + +# --- Валидация недоверенного ввода (аргументы могут прийти из текста задачи) --- + +def bad_line(value: str, field: str) -> str | None: + """Однострочность: перевод строки/управляющий символ ломает индекс и файл.""" + if value is not None and (any(c in value for c in "\n\r") or any(ord(c) < 32 for c in value)): + return f"{field}: перевод строки или управляющий символ запрещён" + return None + + +def bad_slug(slug: str) -> str | None: + if not SLUG_RE.fullmatch(slug): + return f"слаг «{slug}» — только латиница kebab-case (без ../, точек, слэшей)" + return None + + +def bad_reason(reason: str | None) -> str | None: + if reason is None: + return None + if (e := bad_line(reason, "причина")): + return e + if "·" in reason: + return "причина: символ · зарезервирован под разделитель мета-полей" + return None + + +def dir_within_cwd(root: Path) -> bool: + try: + root.resolve().relative_to(Path.cwd().resolve()) + return True + except ValueError: + return False + + +# --- Атомарная запись: падение посреди write не оставит усечённый индекс --- + +def write_atomic(path: Path, text: str) -> None: + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(text, encoding="utf-8") + os.replace(tmp, path) + + +def resolve_dir(explicit: str | None) -> Path: + """Каталог беклога для команд, кроме init. Явный --dir обязан быть внутри cwd.""" + if explicit: + root = Path(explicit) + if not dir_within_cwd(root): + sys.exit(f"--dir вне рабочего каталога: {explicit}") + if not (root / INDEX).is_file(): + sys.exit(f"беклога нет в «{explicit}» (нет {INDEX}); новый проект — backlog.py init") + return root + for candidate in ("docs/backlog", "backlog", "doc/backlog", "docs/tasks"): + if (Path(candidate) / INDEX).is_file(): + return Path(candidate) + sys.exit("каталог беклога не найден, укажи --dir" + " (искал: docs/backlog, backlog, doc/backlog, docs/tasks)") + + +def parse_index(root: Path) -> tuple[dict[str, dict], list[str]]: + """Строки индекса по имени файла + порядок секций (он же порядок приоритетов). + + Дубли имени файла тут схлопываются (побеждает последний) — их отдельно ловит + index_lint, поэтому опираться на этот dict как на полноту нельзя. + """ + entries: dict[str, dict] = {} + sections: list[str] = [] + section = None + for num, line in enumerate((root / INDEX).read_text(encoding="utf-8").splitlines(), 1): + m = SECTION.match(line) + if m: + section = m.group(1) + sections.append(section) + continue + m = INDEX_ENTRY.match(line) + if m: + title, target, hook = m.group(1), m.group(2), (m.group(3) or "").strip() + entries[target] = {"title": title, "section": section, "hook": hook, "line": num} + return entries, sections + + +def index_lint(root: Path) -> list[str]: + """Структурные дефекты индекса, которые схлопнутый dict parse_index не видит: + битые строки-пункты, дубли на один файл, задачи до первой секции приоритета.""" + errors: list[str] = [] + section = None + seen: dict[str, int] = {} + for num, line in enumerate((root / INDEX).read_text(encoding="utf-8").splitlines(), 1): + if SECTION.match(line): + section = SECTION.match(line).group(1) + continue + if not line.startswith("- ["): + continue + m = INDEX_ENTRY.match(line) + if not m: + errors.append(f"{INDEX}:{num}: строка-пункт не по формату" + f" «- [Заголовок](slug.md) — хук»") + continue + target = m.group(2) + if section is None: + errors.append(f"{INDEX}:{num}: {target} стоит до первой секции приоритета") + if target in seen: + errors.append(f"{INDEX}:{num}: дубль строки для {target}" + f" (первая — строка {seen[target]})") + else: + seen[target] = num + return errors + + +def parse_task(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + title = lines[0].removeprefix("#").strip() if lines and lines[0].startswith("#") else "" + kind, bare = PLAIN_TYPE, title + m = TYPE_PREFIX.match(title) + if m: + kind, bare = m.group(1).strip().lower(), m.group(2).strip() + # Мета-строка — первая непустая строка после заголовка (task-format.md). + # Поля разделены `·`, порядок свободный: приоритет распознаётся, где бы он ни + # стоял, а не только первым. Причина не должна содержать `·` — это разделитель. + meta = next((ln.strip() for ln in lines[1:] if ln.strip()), "") + priority, reason, tags = "", "", [] + if META_FIELD.match(meta): + for chunk in meta.split("·"): + f = META_FIELD.match(chunk.strip()) + if not f: + continue + key, value = f.group(1).strip().lower(), f.group(2).strip() + if key in ("приоритет", "priority"): + priority, _, reason = (p.strip() for p in value.partition("—")) + priority = priority.rstrip(".,").lower() + elif key in ("теги", "tags"): + tags = [t.strip().lower() for t in value.split(",") if t.strip()] + return {"title": title, "bare": bare, "type": kind, "priority": priority, + "reason": reason, "tags": tags, "path": path} + + +def tasks_of(root: Path) -> dict[str, dict]: + return {p.name: parse_task(p) for p in sorted(root.glob("*.md")) if p.name not in SERVICE} + + +def touched_map(root: Path) -> dict[str, str]: + """Дата последнего коммита для каждого файла беклога — одним вызовом git. + Ключ — имя файла (в каталоге беклога имена уникальны). Нет git / нет + истории → пустая карта, вызывающий подставит «—».""" + try: + out = subprocess.run(["git", "log", "--format=%as", "--name-only", "--", str(root)], + capture_output=True, text=True).stdout + except FileNotFoundError: + return {} + dates: dict[str, str] = {} + cur = None + for line in out.splitlines(): + if not line.strip(): + continue + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", line): + cur = line # лог новейшие сверху → первая дата и есть последняя правка + elif cur: + dates.setdefault(os.path.basename(line), cur) + return dates + + +def check(root: Path, fix: bool = False) -> int: + if fix: + for line in apply_fixes(root): + print(f"ПОЧИНЕНО {line}") + print() + + entries, sections = parse_index(root) + tasks = tasks_of(root) + known = {s.lower() for s in sections} + errors: list[str] = [] + notes: list[str] = [] + + for name, task in tasks.items(): + entry = entries.get(name) + if not entry: + errors.append(f"{name}: файла нет в индексе {INDEX}") + if not SLUG.fullmatch(name): + errors.append(f"{name}: слаг не kebab-case латиницей") + if not task["title"]: + errors.append(f"{name}: нет заголовка H1") + if not task["priority"]: + errors.append(f"{name}: нет строки **Приоритет:**") + elif task["priority"] not in known: + errors.append(f"{name}: приоритет «{task['priority']}» не совпадает" + f" ни с одной секцией индекса ({', '.join(sections)})") + elif entry and entry["section"] and entry["section"].lower() != task["priority"]: + errors.append(f"{name}: приоритет в файле «{task['priority']}»," + f" а в индексе секция «{entry['section']}»") + if entry and entry["title"] != task["title"]: + errors.append(f"{name}: заголовок разошёлся\n" + f" файл: {task['title']}\n" + f" индекс: {entry['title']}") + if entry and not entry["hook"]: + notes.append(f"{name}: строка индекса без хука — по ней не выбрать задачу") + if task["type"] not in TYPES and task["type"] != PLAIN_TYPE: + notes.append(f"{name}: тип «{task['type']}» вне словаря" + f" ({'/'.join(TYPES)} или без префикса)") + if "" + write_atomic(path, f"# {title_full}\n\n{meta}\n\n{body}\n") + entry = f"- [{title_full}]({a.slug}.md)" + (f" — {a.hook}" if a.hook else "") + insert_entry(lines, section, entry) + save_index(root, lines) + print(f"создано: {a.slug}.md в секции «{section}»; допиши тело редактором") + if not a.hook: + print(f" без хука — задай: backlog.py edit {a.slug} --hook …") + return 0 + + +def cmd_edit(root: Path, a: argparse.Namespace) -> int: + for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_line(a.hook, "хук")): + if err: + return fail(err) + if a.title is None and a.hook is None and a.type is None: + return fail("нечего менять: дай --title, --hook или --type") + path = root / f"{a.slug}.md" + if not path.exists(): + return fail(f"{a.slug}.md не найден") + lines = load_index(root) + ei = find_entry_index(lines, a.slug) + if ei is None: + return fail(f"строки индекса для {a.slug} нет") + task = parse_task(path) + if a.title is not None and not a.title.strip(): + return fail("пустой заголовок") + bare = a.title if a.title is not None else task["bare"] + kind = task["type"] if a.type is None else a.type.strip().lower() + prefix = "" if kind in ("", PLAIN_TYPE) else f"[{kind}] " + h1 = f"{prefix}{bare}" + flines = path.read_text(encoding="utf-8").splitlines() + if not flines or not flines[0].startswith("#"): + return fail(f"{a.slug}.md без заголовка H1 — прогони check") + flines[0] = f"# {h1}" + write_atomic(path, "\n".join(flines) + "\n") + m = INDEX_ENTRY.match(lines[ei]) + hook = a.hook if a.hook is not None else (m.group(3) or "").strip() + lines[ei] = f"- [{h1}]({a.slug}.md)" + (f" — {hook}" if hook else "") + save_index(root, lines) + print(f"{a.slug}: обновлено (заголовок/хук/тип)") + return 0 + + +def cmd_move(root: Path, a: argparse.Namespace) -> int: + for err in (bad_slug(a.slug), bad_reason(a.reason)): + if err: + return fail(err) + path = root / f"{a.slug}.md" + if not path.exists(): + return fail(f"{a.slug}.md не найден") + lines = load_index(root) + ei = find_entry_index(lines, a.slug) + if ei is None: + return fail(f"строки индекса для {a.slug} нет") + hi, section = find_section(lines, a.priority) + if hi is None: + avail = ", ".join(n for _, n in section_headers(lines)) + return fail(f"нет секции приоритета «{a.priority}» (есть: {avail})") + if not update_priority(path, section, a.reason): + return fail(f"{a.slug}.md без мета-строки **Приоритет:** — прогони check и почини") + entry = lines.pop(ei) + insert_entry(lines, section, entry) + save_index(root, lines) + print(f"{a.slug}: перенесено в «{section}»") + return 0 + + +def cmd_close(root: Path, a: argparse.Namespace) -> int: + for err in (bad_slug(a.slug), bad_reason(a.reason)): + if err: + return fail(err) + path = root / f"{a.slug}.md" + if not path.exists(): + return fail(f"{a.slug}.md не найден") + lines = load_index(root) + ei = find_entry_index(lines, a.slug) + if ei is None: + return fail(f"строки индекса для {a.slug} нет") + task = parse_task(path) + if a.reason: + reason = a.reason.rstrip() + dot = "" if reason.endswith((".", "!", "?")) else "." + date = datetime.date.today().isoformat() + bullet = (f"- {date} `{a.slug}` — {task['title']}. Причина: {reason}{dot}" + f" Был приоритет: {task['priority'] or '—'}.") + closed = root / CLOSED + prev = closed.read_text(encoding="utf-8") if closed.exists() else "# Кладбище беклога\n" + if not prev.endswith("\n"): + prev += "\n" + write_atomic(closed, prev + bullet + "\n") + # Порядок: индекс без строки → потом unlink. Обратный порядок оставил бы в + # индексе ссылку в никуда, если бы unlink упал. + lines.pop(ei) + save_index(root, lines) + path.unlink() + print(f"{a.slug}: {'на кладбище + удалено' if a.reason else 'удалено (реализовано)'}") + return 0 + + +def cmd_init(root: Path, a: argparse.Namespace) -> int: + if not dir_within_cwd(root): + return fail(f"--dir вне рабочего каталога: {root}") + index = root / INDEX + if index.exists(): + return fail(f"{index} уже есть — беклог заведён") + sections, seen = [], set() + for s in (s.strip() for s in a.sections.split(",")): + if s and s.lower() not in seen: + sections.append(s) + seen.add(s.lower()) + if not sections: + return fail("пустой список секций") + root.mkdir(parents=True, exist_ok=True) + preamble = ("# Беклог\n\n" + "Одна задача = один файл `.md` + строка в этом индексе.\n" + "Приоритет — грубая оценка «ценность / стоимость». Спекулятивные\n" + "задачи помечены `[idea]` в заголовке. Ведётся скиллом `backlog`.\n\n") + write_atomic(index, preamble + "".join(f"## {s}\n\n" for s in sections)) + closed = root / CLOSED + if not closed.exists(): + write_atomic(closed, + "# Кладбище беклога\n\n" + "Задачи, покинувшие беклог без реализации. Пишется `backlog.py close`.\n\n" + "\n") + print(f"беклог заведён: {root} (секции: {', '.join(sections)})") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(prog="backlog.py") + sub = ap.add_subparsers(dest="command", required=True) + + p = sub.add_parser("check", help="согласованность файлов и индекса") + p.add_argument("--dir") + p.add_argument("--fix", action="store_true", + help="починить безопасный дрейф (секция, заголовок, дубли)") + + p = sub.add_parser("list", help="список задач") + p.add_argument("--dir") + p.add_argument("--stale", action="store_true") + p.add_argument("--priority") + p.add_argument("--type") + p.add_argument("--tag") + + p = sub.add_parser("add", help="создать задачу") + p.add_argument("--dir") + p.add_argument("--slug", required=True) + p.add_argument("--title", required=True) + p.add_argument("--priority", required=True) + p.add_argument("--type", choices=TYPES) + p.add_argument("--hook") + p.add_argument("--reason") + p.add_argument("--tag") + + p = sub.add_parser("edit", help="сменить заголовок/хук/тип") + p.add_argument("slug") + p.add_argument("--title") + p.add_argument("--hook") + p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE)) + p.add_argument("--dir") + + p = sub.add_parser("move", help="перенести в другую секцию приоритета") + p.add_argument("slug") + p.add_argument("--priority", required=True) + p.add_argument("--reason") + p.add_argument("--dir") + + p = sub.add_parser("close", help="закрыть задачу (кладбище или удаление)") + p.add_argument("slug") + g = p.add_mutually_exclusive_group(required=True) + g.add_argument("--reason", help="причина отказа → строка на кладбище") + g.add_argument("--implemented", action="store_true", help="реализовано → просто удалить") + p.add_argument("--dir") + + p = sub.add_parser("init", help="завести пустой беклог") + p.add_argument("--dir") + p.add_argument("--sections", default="высокий,средний,низкий") + + a = ap.parse_args() + if a.command == "init": + return cmd_init(Path(a.dir or "docs/backlog"), a) + root = resolve_dir(a.dir) + dispatch = { + "check": lambda: check(root, a.fix), + "list": lambda: list_tasks(root, a), + "add": lambda: cmd_add(root, a), + "edit": lambda: cmd_edit(root, a), + "move": lambda: cmd_move(root, a), + "close": lambda: cmd_close(root, a), + } + return dispatch[a.command]() + + +if __name__ == "__main__": + sys.exit(main())