удалён av-dev-backlog: заморозка стоила дороже, чем удаление
Плагин был помечен устаревшим решением Q и жил до перевода jellybit. Удалён раньше этого срока: условие пережило свою причину. Заморозка выглядела бесплатной, а платила собой в каждой проверке репозитория — exclude в pyproject.toml, SKIP_DIRS в copies.py, два абзаца README, оговорка в описании маркетплейса, чтобы не ловить триггер «добавь задачу в беклог». Пять исключений ради 706 строк, которые никто не читает, и каждое надо объяснять всякий раз, когда спрашивают, почему проверка обходит каталог. Причина условия отпала раньше названного срока. docs/backlog/ читает не backlog.py, а av-dev-pm:tasks — adopt.md и адаптер в tasks.py держат ту же раскладку как вход миграции. Плагин перестал быть единственным, кто её знает, ещё когда писался adopt, и «живёт до перевода последнего проекта» с тех пор охраняло пустоту. Перевод jellybit на канон это не задевает. Порядок вышел обратный ожидаемому: плагин удалён из маркетплейса, а с jellybit снят после. Ожидалась ручная чистка enabledPlugins и installed_plugins.json, но claude plugin uninstall отработал штатно — он идёт по реестру, а не по манифесту маркетплейса. Раздел «Снятие» в README переписан с частного случая на общую процедуру, предупреждение заменено проверенным фактом. Записи Q и HH получили парные статусы, тема 30 в DECISIONS несёт причины и три следствия, пункт 5 TODO отмечен, открытый вопрос из REMAINING убран. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,11 +19,6 @@
|
|||||||
"name": "av-dev-git",
|
"name": "av-dev-git",
|
||||||
"source": "./av-dev-git",
|
"source": "./av-dev-git",
|
||||||
"description": "Git-обвязка для личных проектов. Скилл commit — сообщения коммитов в личном стиле (русский, scope-префикс, средняя детализация, без co-authored)."
|
"description": "Git-обвязка для личных проектов. Скилл commit — сообщения коммитов в личном стиле (русский, scope-префикс, средняя детализация, без co-authored)."
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "av-dev-backlog",
|
|
||||||
"source": "./av-dev-backlog",
|
|
||||||
"description": "УСТАРЕЛ, заменён плагином av-dev-pm. Старый формат беклога: один каталог задач с индексом README и приоритетами секциями. Оставлен до перевода последнего проекта; новые проекты не подключают."
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-3
@@ -373,8 +373,9 @@ openspec/
|
|||||||
докладывает исход, записей учёта не трогает.
|
докладывает исход, записей учёта не трогает.
|
||||||
|
|
||||||
**Q. `av-dev-backlog` помечается устаревшим и остаётся** до перевода jellybit.
|
**Q. `av-dev-backlog` помечается устаревшим и остаётся** до перевода jellybit.
|
||||||
Описание переписывается так, чтобы не ловить триггер «добавь задачу в беклог» —
|
*(заменено на тему 30: плагин удалён раньше этого срока — условие пережило свою
|
||||||
иначе агент выбирает между ним и `av-dev-pm` случайно.
|
причину.)* Описание переписывается так, чтобы не ловить триггер «добавь задачу в
|
||||||
|
беклог» — иначе агент выбирает между ним и `av-dev-pm` случайно.
|
||||||
|
|
||||||
### Что из этого следует
|
### Что из этого следует
|
||||||
|
|
||||||
@@ -730,7 +731,9 @@ pyrefly: в окружении нет ничего, кроме линтеров,
|
|||||||
опечатка, и три этих правила давали **311 срабатываний из 338** — шум, в котором
|
опечатка, и три этих правила давали **311 срабатываний из 338** — шум, в котором
|
||||||
тонут остальные 27.
|
тонут остальные 27.
|
||||||
|
|
||||||
**HH. `av-dev-backlog` исключён из проверки.** Плагин помечен устаревшим и живёт
|
**HH. `av-dev-backlog` исключён из проверки.** *(исчерпано темой 30: плагин
|
||||||
|
удалён, исключение снято из `pyproject.toml` и `copies.py`.)* Плагин помечен
|
||||||
|
устаревшим и живёт
|
||||||
до перевода последнего проекта, после чего удаляется целиком. Шесть его находок
|
до перевода последнего проекта, после чего удаляется целиком. Шесть его находок
|
||||||
косметические (`os.replace`, `l` как имя), а правка замороженного кода без
|
косметические (`os.replace`, `l` как имя), а правка замороженного кода без
|
||||||
тестов — риск без выгоды. Исключение уходит вместе с плагином.
|
тестов — риск без выгоды. Исключение уходит вместе с плагином.
|
||||||
@@ -2041,3 +2044,43 @@ dev-skills — **маркетплейс плагинов**: скилл комм
|
|||||||
113. **Число в документе — обязанность, которую никто не берёт.** Счётчик тем,
|
113. **Число в документе — обязанность, которую никто не берёт.** Счётчик тем,
|
||||||
коммитов, правок протухает молча; формулировка без числа дешевле его
|
коммитов, правок протухает молча; формулировка без числа дешевле его
|
||||||
сопровождения.
|
сопровождения.
|
||||||
|
|
||||||
|
## 30. `av-dev-backlog` удалён (2026-08-05)
|
||||||
|
|
||||||
|
Плагин был помечен устаревшим решением Q и жил до перевода jellybit. Удалён
|
||||||
|
раньше этого срока.
|
||||||
|
|
||||||
|
**ААББВВ. Замороженный плагин стоит дороже, чем кажется.** Он не менялся, но
|
||||||
|
платил собой в каждой проверке репозитория: `exclude` в `pyproject.toml`,
|
||||||
|
`SKIP_DIRS` в `copies.py`, два абзаца README, оговорка в описании маркетплейса,
|
||||||
|
чтобы не ловить триггер «добавь задачу в беклог». Пять исключений ради кода,
|
||||||
|
который никто не читает, — и каждое надо было объяснять всякий раз, когда
|
||||||
|
кто-нибудь спрашивал, почему проверка обходит каталог.
|
||||||
|
|
||||||
|
**ААББГГ. Понимание старой раскладки уехало из плагина раньше самого плагина.**
|
||||||
|
`docs/backlog/` читает не `backlog.py`, а `av-dev-pm:tasks` — `adopt.md` и
|
||||||
|
адаптер в `tasks.py` держат ту же раскладку как **вход миграции**. Плагин
|
||||||
|
перестал быть единственным, кто её знает, ещё когда писался `adopt`; условие
|
||||||
|
«живёт до перевода последнего проекта» с тех пор охраняло пустоту.
|
||||||
|
|
||||||
|
**ААББДД. Опасение про порядок снятия не подтвердилось.** Удаление опередило
|
||||||
|
снятие: на jellybit плагин оставался включённым, когда записи в маркетплейсе уже
|
||||||
|
не было, и ожидалась ручная чистка `enabledPlugins` и `installed_plugins.json`.
|
||||||
|
`claude plugin uninstall` отработал штатно — он идёт **по реестру, а не по
|
||||||
|
манифесту маркетплейса**, и отсутствие записи там ему безразлично. Предупреждение
|
||||||
|
из README снято, вместо него записан проверенный факт.
|
||||||
|
|
||||||
|
### Что из этого следует
|
||||||
|
|
||||||
|
114. **Устаревшее удаляют, а не замораживают.** Заморозка выглядит бесплатной,
|
||||||
|
но растекается исключениями по конфигам и требует объяснения в каждом
|
||||||
|
месте, куда попала. Если удалять пока рано — назвать условие и срок; условие
|
||||||
|
без срока переживает свою причину.
|
||||||
|
115. **Условие «живёт до X» проверяют на живость, а не на X.** Здесь X (перевод
|
||||||
|
jellybit) не наступил, но причина условия отпала раньше: знание раскладки
|
||||||
|
переехало в `adopt`. Перепроверять надо основание, иначе условие держит само
|
||||||
|
себя.
|
||||||
|
116. **Порядок снятия и удаления из маркетплейса свободный.** `uninstall` живёт
|
||||||
|
реестром, манифест ему не нужен. Правило записано после проверки, а не
|
||||||
|
из осторожности, — и осторожность здесь стоила бы лишнего абзаца в README
|
||||||
|
про починку, которой не бывает.
|
||||||
|
|||||||
@@ -33,8 +33,6 @@
|
|||||||
архитектура, обязательный триаж. Девять агентов-проходов, четыре ступени
|
архитектура, обязательный триаж. Девять агентов-проходов, четыре ступени
|
||||||
стоимости: `quick`, `standard`, `wide`, `deep`.
|
стоимости: `quick`, `standard`, `wide`, `deep`.
|
||||||
- **av-dev-git** — `commit`: сообщения в личном стиле.
|
- **av-dev-git** — `commit`: сообщения в личном стиле.
|
||||||
- **av-dev-backlog** — **устарел**, заменён `av-dev-pm`. Живёт до перевода
|
|
||||||
последнего проекта; как снять с проекта — [Снятие](#снятие).
|
|
||||||
|
|
||||||
Соглашение об именах: имя **плагина** длинное с префиксом `av-dev-`, имена
|
Соглашение об именах: имя **плагина** длинное с префиксом `av-dev-`, имена
|
||||||
**скилов** внутри — короткие. Вызов выходит вида `/av-dev-<плагин>:<скилл>`.
|
**скилов** внутри — короткие. Вызов выходит вида `/av-dev-<плагин>:<скилл>`.
|
||||||
@@ -183,25 +181,25 @@ EOF
|
|||||||
|
|
||||||
## Снятие
|
## Снятие
|
||||||
|
|
||||||
Действие, обратное подключению. Актуально для `av-dev-backlog`: плагин устарел,
|
Действие, обратное подключению.
|
||||||
и с каждого проекта снимается по мере перевода задач на канон `docs/tasks/`.
|
|
||||||
|
|
||||||
**Сначала перевод, потом снятие.** Задачи переводит `/av-dev-pm:canon`
|
|
||||||
(`docs/backlog/` → `docs/tasks/`). Снять плагин раньше — остаться со старой
|
|
||||||
раскладкой и без скилла, который её понимает.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/project
|
cd /path/to/project
|
||||||
claude plugin uninstall av-dev-backlog@av-dev-skills --scope project
|
claude plugin uninstall <плагин>@av-dev-skills --scope project
|
||||||
```
|
```
|
||||||
|
|
||||||
Команда правит два места: убирает строку из `enabledPlugins` в
|
Команда правит два места: убирает строку из `enabledPlugins` в
|
||||||
`.claude/settings.json` проекта и запись из реестра
|
`.claude/settings.json` проекта и запись из реестра
|
||||||
`~/.claude/plugins/installed_plugins.json`. Снимок в
|
`~/.claude/plugins/installed_plugins.json`. Снимок в
|
||||||
`~/.claude/plugins/cache/av-dev-skills/av-dev-backlog/<версия>/` не трогает — он
|
`~/.claude/plugins/cache/av-dev-skills/<плагин>/<версия>/` не трогает — он
|
||||||
общий для всех проектов. Маркетплейс тоже остаётся: `extraKnownMarketplaces`
|
общий для всех проектов. Маркетплейс тоже остаётся: `extraKnownMarketplaces`
|
||||||
нужен остальным плагинам.
|
нужен остальным плагинам.
|
||||||
|
|
||||||
|
**Удалять из маркетплейса можно и до снятия с проектов.** `uninstall` идёт по
|
||||||
|
реестру, а не по `marketplace.json`, и снимает плагин, записи о котором в
|
||||||
|
манифесте уже нет. Проверено на `av-dev-backlog`: удалён из маркетплейса,
|
||||||
|
снят с jellybit после — команда отработала штатно.
|
||||||
|
|
||||||
`--scope project` обязателен по той же причине, что и при установке: умолчание у
|
`--scope project` обязателен по той же причине, что и при установке: умолчание у
|
||||||
команды — user. `cd` в проект обязателен, но здесь ошибка слышна — вызванная не
|
команды — user. `cd` в проект обязателен, но здесь ошибка слышна — вызванная не
|
||||||
оттуда, команда откажется словами `is not installed in project scope`, а не
|
оттуда, команда откажется словами `is not installed in project scope`, а не
|
||||||
@@ -246,10 +244,6 @@ uv run pyrefly check # типы
|
|||||||
линтеров, и любой сторонний импорт у него не разрешается. Список запретов —
|
линтеров, и любой сторонний импорт у него не разрешается. Список запретов —
|
||||||
не перечень мира, настоящий страж второй.
|
не перечень мира, настоящий страж второй.
|
||||||
|
|
||||||
`av-dev-backlog` из проверки исключён намеренно: плагин помечен устаревшим и
|
|
||||||
живёт до перевода последнего проекта, после чего удаляется целиком. Правки в
|
|
||||||
замороженный код — риск без выгоды.
|
|
||||||
|
|
||||||
## Проверка фронтматтеров
|
## Проверка фронтматтеров
|
||||||
|
|
||||||
Фронтматтер читает не человек, а загрузчик: по `name` он разрешает вызов, по
|
Фронтматтер читает не человек, а загрузчик: по `name` он разрешает вызов, по
|
||||||
|
|||||||
@@ -73,10 +73,6 @@ check` сверяет версию, но не то, что миграционн
|
|||||||
касался, и пересмотр, отменяющий решение из документа, к которому не
|
касался, и пересмотр, отменяющий решение из документа, к которому не
|
||||||
притрагивались, он не увидит. Механической проверки по-прежнему нет.
|
притрагивались, он не увидит. Механической проверки по-прежнему нет.
|
||||||
|
|
||||||
**Что делать с `av-dev-backlog` после перевода jellybit.** Помечен устаревшим и
|
|
||||||
переписан так, чтобы не ловить триггер. Удалять его из маркетплейса или оставить
|
|
||||||
как есть — решится, когда jellybit переедет.
|
|
||||||
|
|
||||||
## Известные пределы — приняты, чинить не планируется
|
## Известные пределы — приняты, чинить не планируется
|
||||||
|
|
||||||
**Транзакций на несколько файлов нет.** POSIX её не даёт без журнала. Окно сжато
|
**Транзакций на несколько файлов нет.** POSIX её не даёт без журнала. Окно сжато
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
`research` (сырьё: тип есть, «Вопрос» пуст), logical-title-model → ADR (H)
|
`research` (сырьё: тип есть, «Вопрос» пуст), logical-title-model → ADR (H)
|
||||||
- [ ] `docs/backlog/` → `docs/tasks/`
|
- [ ] `docs/backlog/` → `docs/tasks/`
|
||||||
- [ ] удалить проектные копии скиллов и агентов (4 из REMAINING)
|
- [ ] удалить проектные копии скиллов и агентов (4 из REMAINING)
|
||||||
- [ ] `av-dev-backlog` удалить из маркетплейса
|
- [x] `av-dev-backlog` удалить из маркетплейса и снять с проекта (тема 30)
|
||||||
|
|
||||||
## 6. Канон версии 3 — повысить живые проекты (тема 17)
|
## 6. Канон версии 3 — повысить живые проекты (тема 17)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "av-dev-backlog",
|
|
||||||
"description": "УСТАРЕЛ, заменён плагином av-dev-pm. Старый формат беклога: один каталог задач с индексом README и приоритетами секциями, без целей и спринтов. Оставлен до перевода последнего проекта, который на нём ещё живёт; новые проекты не подключают.",
|
|
||||||
"author": {
|
|
||||||
"name": "Anton Vakhrushev",
|
|
||||||
"email": "anwinged@gmail.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
---
|
|
||||||
name: backlog
|
|
||||||
description: "УСТАРЕЛ — используй скилл av-dev-pm:tasks. Старый формат беклога (один каталог задач, индекс README, приоритеты секциями, без целей и спринтов). Вызывать ТОЛЬКО в проекте, который на этот формат ещё не переведён, и только если прямо названо имя backlog. Во всех остальных случаях, включая любую просьбу завести задачу, идею или разобрать находки ревью, работает av-dev-pm:tasks."
|
|
||||||
---
|
|
||||||
|
|
||||||
> **Этот скилл устарел.** Формат заменён каноном `docs/tasks/` из плагина
|
|
||||||
> `av-dev-pm` (цели вместо приоритетов, спринт с заморозкой набора, `REJECTED.md`
|
|
||||||
> с причинами). Перевод проекта делает скилл `av-dev-pm:canon`. Скилл оставлен до
|
|
||||||
> перевода последнего проекта, который на нём ещё живёт, и будет удалён.
|
|
||||||
|
|
||||||
|
|
||||||
# Беклог
|
|
||||||
|
|
||||||
Беклог — каталог markdown-файлов: одна задача = один файл `<slug>.md`, плюс
|
|
||||||
строка в индексе `README.md`. Скилл ведёт беклог: заводит, чистит, приоритизирует,
|
|
||||||
дробит, штурмует идеи. **Реализацией не занимается** — это дело пайплайна задачи.
|
|
||||||
|
|
||||||
## Три правила, из которых всё следует
|
|
||||||
|
|
||||||
Ситуация не покрыта инструкцией — решай по ним.
|
|
||||||
|
|
||||||
1. **Беклог гниёт с той стороны, где его пополняют.** Заведение — самая частая
|
|
||||||
операция и с худшим отказом: из одного разговора рождается пять файлов, и
|
|
||||||
груминг потом разгребает то, чего не надо было заводить. Дедупликация и фильтр
|
|
||||||
на входе дешевле любой чистки. Заводим только то, что **не делаем сейчас** и о
|
|
||||||
потере чего пожалеем.
|
|
||||||
2. **Файл — источник истины, индекс производен.** Разошлись — неправ индекс.
|
|
||||||
Согласованность механизируема и проверяется командой, а не вниманием: всё, что
|
|
||||||
ловит `backlog.py check`, не должно попадать ни в чек-лист, ни в промпт.
|
|
||||||
3. **Причина переживает запись.** Приоритет без причины будет переспорен на
|
|
||||||
следующем груминге; выкинутая без причины задача вернётся через квартал тем же
|
|
||||||
текстом. Реализованная задача оставляет след в коммите и спеке — выкинутая не
|
|
||||||
оставляет ничего, поэтому у неё есть кладбище.
|
|
||||||
|
|
||||||
## Инструмент (`backlog.py`)
|
|
||||||
|
|
||||||
Пусть `bl="$CLAUDE_PLUGIN_ROOT/skills/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 <slug> --priority <новый> --reason <причина>`; причина
|
|
||||||
уезжает в мета-строку.
|
|
||||||
- Повышаешь — назови, **что именно эта задача обгоняет**. Повышение без
|
|
||||||
проигравшего это не приоритизация, а согласие с последним, кто говорил.
|
|
||||||
- Задача, давно лежащая в нижней секции и не двигавшаяся (по дате git), —
|
|
||||||
кандидат на кладбище, а не на новый круг «оставить как есть».
|
|
||||||
|
|
||||||
### Декомпозиция и штурм идеи
|
|
||||||
|
|
||||||
[references/split.md](references/split.md). Обе операции превращают одну запись в
|
|
||||||
несколько, и у обеих есть проверяемый тест: части должны **мерджиться порознь** и
|
|
||||||
**каждая давать видимую пользу**, а у штурма исход «выкинуть» — полноправный.
|
|
||||||
|
|
||||||
## Общее для всех сценариев
|
|
||||||
|
|
||||||
- **Кладбище.** Задача уходит из беклога без реализации → `close <slug> --reason
|
|
||||||
<причина>`: скрипт пишет строку в `CLOSED.md` (дата, слаг, заголовок, причина,
|
|
||||||
бывший приоритет) и удаляет файл со строкой индекса. Реализованные туда не идут
|
|
||||||
— у них есть коммит, спека и ADR; для них `close <slug> --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, не берёт задачу в работу — этим
|
|
||||||
занимается пайплайн задачи проекта, а этот скилл владеет только форматом и
|
|
||||||
содержимым беклога. Не решает за пользователя, что важно. Не переоформляет
|
|
||||||
существующие задачи «заодно»: правится то, чего касается операция.
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Задачи из аудита и ревью
|
|
||||||
|
|
||||||
Ревью и аудиты — код-ревью, архитектурный проход, аудит безопасности, любой
|
|
||||||
разбор другим агентом — порождают находки, часть которых становится задачами
|
|
||||||
беклога. Это отдельный интейк со своей опасностью, **зеркальной** интейку из
|
|
||||||
диалога.
|
|
||||||
|
|
||||||
- Интейк из диалога грешит переполнением: из одной мысли рождается пять файлов.
|
|
||||||
- Интейк из ревью грешит сваливанием: сорок сырых находок превращаются в сорок
|
|
||||||
файлов. Беклог раздувается, а следующий груминг склеивает их обратно.
|
|
||||||
|
|
||||||
Защита от сваливания — та же, что в самом ревью: **кластеризация по причине, а не
|
|
||||||
файл-на-находку.** Если у ревью был триаж — половина работы уже сделана, бери его
|
|
||||||
выход. Если нет — триажируй сам, прежде чем заводить.
|
|
||||||
|
|
||||||
## Находка агента — не задача
|
|
||||||
|
|
||||||
Мнение агента — **гипотеза, пока у неё нет свидетельства** (падающий тест,
|
|
||||||
воспроизводимый шаг, положение гайда). Согласие нескольких находок само по себе
|
|
||||||
достоверность не повышает: это один источник, высказавшийся несколько раз.
|
|
||||||
|
|
||||||
Отсюда фильтр входа, поверх обычного «не делаем сейчас + пожалеем о потере»:
|
|
||||||
|
|
||||||
- **Находка со свидетельством**, отложенная к исполнению → **задача**.
|
|
||||||
Свидетельство и последствие переносим в тело — это её «почему», то самое, что
|
|
||||||
переживает запись.
|
|
||||||
- **Находка без свидетельства / низкой уверенности** → **идея** (`[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`.
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# Груминг беклога
|
|
||||||
|
|
||||||
Цель — выкинуть то, что перестало быть задачей, и вернуть остальному честное
|
|
||||||
состояние. Не «пересмотреть всё», а «пересмотреть порцию до конца».
|
|
||||||
|
|
||||||
## Порция и правило остановки
|
|
||||||
|
|
||||||
Тридцать задач за один заход — это усталость и штамповка: последние десять
|
|
||||||
получат «оставить» не потому, что живы, а потому, что сессия затянулась.
|
|
||||||
|
|
||||||
- **5–8 задач за сессию.** Больше — только если пользователь настаивает, и тогда
|
|
||||||
разбей на явные порции с промежуточным докладом.
|
|
||||||
- **Отбор порции** — один из:
|
|
||||||
- `backlog.py list --stale` — самые залежавшиеся по дате последней правки в
|
|
||||||
git; поле «дата касания» заводить не надо, git её уже хранит;
|
|
||||||
- одна секция приоритета целиком;
|
|
||||||
- один тег (`--tag`) — например, задачи, пришедшие из одного ревью;
|
|
||||||
- список от пользователя.
|
|
||||||
- **Останавливайся на границе порции**, даже если «ещё чуть-чуть осталось».
|
|
||||||
|
|
||||||
## Что делать с каждой задачей
|
|
||||||
|
|
||||||
Сперва то, что не требует ничьего решения:
|
|
||||||
|
|
||||||
1. **Проверь, не сделано ли уже.** Задача, реализованная попутно в соседнем
|
|
||||||
изменении, — самая частая находка груминга. Смотри код, спеки, историю
|
|
||||||
коммитов по ключевым словам задачи. Удаление задачи «как реализованной» —
|
|
||||||
деструктивно и без следа (кладбище для реализованных не пишется), поэтому
|
|
||||||
порог улики жёсткий: удаляем (`close <slug> --implemented`), только имея
|
|
||||||
**конкретный коммит или строку спеки**, закрывающие задачу, и ссылка на них
|
|
||||||
идёт в доклад. Есть лишь косвенные признаки — не удаляй сам, вынеси в пачку
|
|
||||||
вопросов. Сделана частично → задача сжимается до остатка: тело правишь
|
|
||||||
редактором, заголовок и хук — через `edit <slug> --title … --hook …`.
|
|
||||||
2. **Проверь, не отменена ли решением.** ADR, спека или архивный change мог
|
|
||||||
закрыть вопрос иначе. Тогда `close <slug> --reason "<ссылка на решение>"`.
|
|
||||||
3. **Проверь пересечения внутри порции.** Две задачи об одном — содержимое в
|
|
||||||
одну, вторую `close <slug> --reason "слита с <другой-slug>"`.
|
|
||||||
|
|
||||||
Затем — то, что решает пользователь:
|
|
||||||
|
|
||||||
4. **Жива ли она вообще.** Контекст мог измениться: ушла зависимость, отпал
|
|
||||||
сценарий, обошли иначе. Здесь и звучит вопрос о выкидывании.
|
|
||||||
5. **Тот ли приоритет** (тест и правила — в SKILL.md и task-format.md).
|
|
||||||
6. **Задача ли это по-прежнему.** Не проходит тест «готова к взятию» → `edit
|
|
||||||
<slug> --type idea`, и её дальнейшая судьба — штурм, а не приоритизация.
|
|
||||||
Разрослась → `edit <slug> --type epic`, дальше декомпозиция.
|
|
||||||
|
|
||||||
## Храповик
|
|
||||||
|
|
||||||
Сильно залежавшаяся задача — сигнал сама по себе: её либо ни разу не собирались
|
|
||||||
делать, либо нечем взять. Измеряй наблюдаемым — датой последней правки из git
|
|
||||||
(`backlog.py list --stale` ставит такие первыми); счётчик «сколько грумингов
|
|
||||||
пережила» нигде не хранится, поэтому на него не опирайся.
|
|
||||||
|
|
||||||
Задача из верхних строк `--stale`, которую и этот заход оставляет без изменений,
|
|
||||||
**либо двигается (вверх или на кладбище), либо остаётся с явно записанной
|
|
||||||
причиной**, почему её держим (`move <slug> --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` после правок; результат — строкой в докладе.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Декомпозиция и мозговой штурм
|
|
||||||
|
|
||||||
Обе операции превращают одну запись беклога в несколько (или в ноль). Разница в
|
|
||||||
входе: декомпозиция дробит **готовую задачу**, штурм прорабатывает **идею**,
|
|
||||||
которая ещё не задача.
|
|
||||||
|
|
||||||
## Тест декомпозиции
|
|
||||||
|
|
||||||
Задачу можно дробить, только если части удовлетворяют **обоим** условиям:
|
|
||||||
|
|
||||||
1. **Мерджатся независимо.** Часть Б не требует, чтобы часть А была уже влита.
|
|
||||||
Есть порядок «сперва А, потом Б, иначе не собрать» → это не декомпозиция, а
|
|
||||||
план реализации: шаги остаются **внутри одного файла** в разделе «Шаги».
|
|
||||||
2. **Каждая даёт видимую пользу.** Часть, полезная только в комплекте с другой, —
|
|
||||||
не самостоятельная задача. Пользу проверяй тем же тестом «готова к взятию»
|
|
||||||
(task-format): что станет наблюдаемо иначе именно от этой части.
|
|
||||||
|
|
||||||
Не проходит хотя бы одно — **не дроби**. Ложная декомпозиция плодит файлы,
|
|
||||||
которые нельзя взять поодиночке, и груминг потом их склеивает обратно.
|
|
||||||
|
|
||||||
## Что делать с родителем
|
|
||||||
|
|
||||||
После разделения родитель **не остаётся** третьей висящей строкой:
|
|
||||||
|
|
||||||
- части полностью замещают его → `close <slug> --reason "разложена на a, b"`.
|
|
||||||
Кладбище здесь — не «выкинули», а именно тот след, что переживает запись:
|
|
||||||
через квартал вопрос «куда делась задача X» отвечается строкой кладбища со
|
|
||||||
ссылками на наследников, а не археологией git;
|
|
||||||
- родитель осмыслен как зонтик → `edit <slug> --type epic`, тело — ссылки на
|
|
||||||
задачи-части, своих шагов у него нет.
|
|
||||||
|
|
||||||
Одно и то же не должно лежать и в родителе, и в части. Задвоение — то же
|
|
||||||
расхождение, что ловит `check`, только внутри тел.
|
|
||||||
|
|
||||||
## Мозговой штурм идеи
|
|
||||||
|
|
||||||
Идея (`[idea]`) не проходит тест «готова к взятию»: неясно, что именно делаем.
|
|
||||||
Штурм проясняет — и это **generative-операция, а не applicative**.
|
|
||||||
|
|
||||||
Applicative-штурм («перечисли задачи, следующие из идеи») выдаёт очевидное:
|
|
||||||
перечисляется то, что уже видно в формулировке. Ценное — на уровень выше.
|
|
||||||
|
|
||||||
1. **Сперва — формы, а не задачи.** Предложи **три разные постановки** идеи и
|
|
||||||
назови **компромисс каждой**: что она даёт, чем платит, что оставляет за
|
|
||||||
бортом. Если получилась одна постановка — штурм не состоялся, это applicative.
|
|
||||||
2. **Вынеси формы пользователю** через `AskUserQuestion` с компромиссами. Рамку
|
|
||||||
выбирает он: это продуктовое решение, не механика.
|
|
||||||
3. **Только выбранную форму** дроби по тесту декомпозиции выше.
|
|
||||||
|
|
||||||
**«Выкинуть» — полноправный исход штурма, а не его неудача.** Проработка, честно
|
|
||||||
показавшая, что пользы нет или она несоразмерна цене, — это результат: идея
|
|
||||||
уезжает на кладбище с этой самой причиной, и та причина гасит её повторное
|
|
||||||
появление.
|
|
||||||
|
|
||||||
## Доклад
|
|
||||||
|
|
||||||
- Идея/задача на входе, выбранная рамка (для штурма), задачи-наследники со
|
|
||||||
слагами и приоритетами.
|
|
||||||
- Судьба родителя: удалён / стал эпиком / выкинут.
|
|
||||||
- `backlog.py check` после правок.
|
|
||||||
- Границы покрытия: какие постановки рассмотрены и какие сознательно отброшены —
|
|
||||||
чтобы штурм не пришлось повторять с нуля.
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# Формат беклога
|
|
||||||
|
|
||||||
Заголовок, мета-строку и строку индекса ставит `backlog.py add` — руками их не
|
|
||||||
пишут. Этот файл описывает, что именно скрипт создаёт и что проверяет `check`;
|
|
||||||
тело задачи (контекст, шаги, ссылки) дописывает агент.
|
|
||||||
|
|
||||||
## Файл задачи
|
|
||||||
|
|
||||||
`<slug>.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. **Почему приоритет такой** — одна строка.
|
|
||||||
|
|
||||||
Не отвечается первый или второй вопрос → это **идея**, её место в штурме, а не в
|
|
||||||
приоритизации. Приоритизировать идеи бессмысленно: сравнивается неизвестно что.
|
|
||||||
|
|
||||||
Отвечается всё, но задача не делается одним заходом и не мерджится целиком →
|
|
||||||
**эпик**, сперва декомпозиция.
|
|
||||||
|
|
||||||
Тест применяется при заведении и на груминге. К старым задачам, которых операция
|
|
||||||
не касается, задним числом не применяется — беклог не переоформляют «заодно».
|
|
||||||
@@ -1,706 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Детерминированный инструмент беклога: файлы задач против индекса README.
|
|
||||||
|
|
||||||
Согласованность беклога — механизируемая вещь, и держать её вниманием агента
|
|
||||||
дорого и ненадёжно. Скрипт не только проверяет, но и **пишет**: создание,
|
|
||||||
переименование, перенос между приоритетами и закрытие правят файл и индекс
|
|
||||||
заодно, так что рассогласовать их вручную нельзя. Всё, что здесь механизировано,
|
|
||||||
не должно попадать ни в промпт, ни в чек-лист человека.
|
|
||||||
|
|
||||||
Источник истины — файл задачи. Индекс производен от файлов: расходятся —
|
|
||||||
неправ индекс.
|
|
||||||
|
|
||||||
Тип задачи — ключевое слово (idea | epic | task); по-английски, как и прочие
|
|
||||||
токены команд. Обычная задача (task) префикса не несёт, idea/epic кодируются
|
|
||||||
префиксом `[idea]`/`[epic]` в заголовке. Текст самой задачи — русский.
|
|
||||||
|
|
||||||
Использование:
|
|
||||||
backlog.py check [--dir DIR] [--fix] согласованность (+ здоровье беклога);
|
|
||||||
--fix чинит безопасный дрейф
|
|
||||||
backlog.py list [--dir DIR] [фильтры] список задач
|
|
||||||
--stale от самой залежавшейся (дата последней правки из git)
|
|
||||||
--priority СЛОВО / --type idea|epic / --tag СЛОВО фильтры
|
|
||||||
backlog.py add --slug S --title T --priority P [--type idea|epic]
|
|
||||||
[--hook H] [--reason R] [--tag a,b] [--dir DIR]
|
|
||||||
создать задачу: файл + строка индекса
|
|
||||||
backlog.py edit S [--title T] [--hook H] [--type idea|epic|task] [--dir DIR]
|
|
||||||
сменить заголовок/хук/тип (файл + индекс)
|
|
||||||
backlog.py move S --priority P [--reason R] [--dir DIR]
|
|
||||||
перенести в другую секцию приоритета
|
|
||||||
backlog.py close S (--reason R | --implemented) [--dir DIR]
|
|
||||||
закрыть: --reason → кладбище + удаление,
|
|
||||||
--implemented → просто удаление (есть коммит)
|
|
||||||
backlog.py init [--dir DIR] [--sections "высокий,средний,низкий"]
|
|
||||||
завести пустой беклог в новом проекте
|
|
||||||
|
|
||||||
Тело задачи (контекст, шаги, ссылки) остаётся агенту — add кладёт лишь заголовок,
|
|
||||||
мета-строку и плейсхолдер; агент дописывает тело редактором.
|
|
||||||
|
|
||||||
Границы безопасности: слаг — только латиница kebab-case (traversal невозможен),
|
|
||||||
--dir обязан быть внутри рабочего каталога, в заголовок/хук/причину не пролезет
|
|
||||||
перевод строки, `·` в причине запрещён (это разделитель мета-полей).
|
|
||||||
|
|
||||||
Язык не зашит инструментально: приоритеты сопоставляются с заголовками секций
|
|
||||||
индекса как есть. Текст задач — русский.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
INDEX = "README.md"
|
|
||||||
CLOSED = "CLOSED.md"
|
|
||||||
SERVICE = {INDEX, CLOSED}
|
|
||||||
|
|
||||||
META_FIELD = re.compile(r"^\*\*(.+?):\*\*\s*(.*)$")
|
|
||||||
INDEX_ENTRY = re.compile(r"^- \[(.+?)\]\((.+?\.md)\)\s*(?:—\s*(.*))?$")
|
|
||||||
SECTION = re.compile(r"^##\s+(.+?)\s*$")
|
|
||||||
TYPE_PREFIX = re.compile(r"^\[(.+?)\]\s*(.*)$")
|
|
||||||
SLUG_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
|
||||||
SLUG = re.compile(SLUG_RE.pattern + r"\.md")
|
|
||||||
# Строка кладбища: - ГГГГ-ММ-ДД `slug` — текст
|
|
||||||
CLOSED_ENTRY = re.compile(r"^- \d{4}-\d{2}-\d{2} `[a-z0-9-]+` — .+")
|
|
||||||
|
|
||||||
TYPES = ("idea", "epic") # непустые типы-ключевые слова, префикс [..] в H1
|
|
||||||
PLAIN_TYPE = "task" # обычная задача — без префикса
|
|
||||||
STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check
|
|
||||||
|
|
||||||
|
|
||||||
# --- Валидация недоверенного ввода (аргументы могут прийти из текста задачи) ---
|
|
||||||
|
|
||||||
def bad_line(value: str, field: str) -> str | None:
|
|
||||||
"""Однострочность: перевод строки/управляющий символ ломает индекс и файл."""
|
|
||||||
if value is not None and (any(c in value for c in "\n\r") or any(ord(c) < 32 for c in value)):
|
|
||||||
return f"{field}: перевод строки или управляющий символ запрещён"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bad_slug(slug: str) -> str | None:
|
|
||||||
if not SLUG_RE.fullmatch(slug):
|
|
||||||
return f"слаг «{slug}» — только латиница kebab-case (без ../, точек, слэшей)"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bad_reason(reason: str | None) -> str | None:
|
|
||||||
if reason is None:
|
|
||||||
return None
|
|
||||||
if (e := bad_line(reason, "причина")):
|
|
||||||
return e
|
|
||||||
if "·" in reason:
|
|
||||||
return "причина: символ · зарезервирован под разделитель мета-полей"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def dir_within_cwd(root: Path) -> bool:
|
|
||||||
try:
|
|
||||||
root.resolve().relative_to(Path.cwd().resolve())
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# --- Атомарная запись: падение посреди write не оставит усечённый индекс ---
|
|
||||||
|
|
||||||
def write_atomic(path: Path, text: str) -> None:
|
|
||||||
tmp = path.with_name(path.name + ".tmp")
|
|
||||||
tmp.write_text(text, encoding="utf-8")
|
|
||||||
os.replace(tmp, path)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_dir(explicit: str | None) -> Path:
|
|
||||||
"""Каталог беклога для команд, кроме init. Явный --dir обязан быть внутри cwd."""
|
|
||||||
if explicit:
|
|
||||||
root = Path(explicit)
|
|
||||||
if not dir_within_cwd(root):
|
|
||||||
sys.exit(f"--dir вне рабочего каталога: {explicit}")
|
|
||||||
if not (root / INDEX).is_file():
|
|
||||||
sys.exit(f"беклога нет в «{explicit}» (нет {INDEX}); новый проект — backlog.py init")
|
|
||||||
return root
|
|
||||||
for candidate in ("docs/backlog", "backlog", "doc/backlog", "docs/tasks"):
|
|
||||||
if (Path(candidate) / INDEX).is_file():
|
|
||||||
return Path(candidate)
|
|
||||||
sys.exit("каталог беклога не найден, укажи --dir"
|
|
||||||
" (искал: docs/backlog, backlog, doc/backlog, docs/tasks)")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_index(root: Path) -> tuple[dict[str, dict], list[str]]:
|
|
||||||
"""Строки индекса по имени файла + порядок секций (он же порядок приоритетов).
|
|
||||||
|
|
||||||
Дубли имени файла тут схлопываются (побеждает последний) — их отдельно ловит
|
|
||||||
index_lint, поэтому опираться на этот dict как на полноту нельзя.
|
|
||||||
"""
|
|
||||||
entries: dict[str, dict] = {}
|
|
||||||
sections: list[str] = []
|
|
||||||
section = None
|
|
||||||
for num, line in enumerate((root / INDEX).read_text(encoding="utf-8").splitlines(), 1):
|
|
||||||
m = SECTION.match(line)
|
|
||||||
if m:
|
|
||||||
section = m.group(1)
|
|
||||||
sections.append(section)
|
|
||||||
continue
|
|
||||||
m = INDEX_ENTRY.match(line)
|
|
||||||
if m:
|
|
||||||
title, target, hook = m.group(1), m.group(2), (m.group(3) or "").strip()
|
|
||||||
entries[target] = {"title": title, "section": section, "hook": hook, "line": num}
|
|
||||||
return entries, sections
|
|
||||||
|
|
||||||
|
|
||||||
def index_lint(root: Path) -> list[str]:
|
|
||||||
"""Структурные дефекты индекса, которые схлопнутый dict parse_index не видит:
|
|
||||||
битые строки-пункты, дубли на один файл, задачи до первой секции приоритета."""
|
|
||||||
errors: list[str] = []
|
|
||||||
section = None
|
|
||||||
seen: dict[str, int] = {}
|
|
||||||
for num, line in enumerate((root / INDEX).read_text(encoding="utf-8").splitlines(), 1):
|
|
||||||
if SECTION.match(line):
|
|
||||||
section = SECTION.match(line).group(1)
|
|
||||||
continue
|
|
||||||
if not line.startswith("- ["):
|
|
||||||
continue
|
|
||||||
m = INDEX_ENTRY.match(line)
|
|
||||||
if not m:
|
|
||||||
errors.append(f"{INDEX}:{num}: строка-пункт не по формату"
|
|
||||||
f" «- [Заголовок](slug.md) — хук»")
|
|
||||||
continue
|
|
||||||
target = m.group(2)
|
|
||||||
if section is None:
|
|
||||||
errors.append(f"{INDEX}:{num}: {target} стоит до первой секции приоритета")
|
|
||||||
if target in seen:
|
|
||||||
errors.append(f"{INDEX}:{num}: дубль строки для {target}"
|
|
||||||
f" (первая — строка {seen[target]})")
|
|
||||||
else:
|
|
||||||
seen[target] = num
|
|
||||||
return errors
|
|
||||||
|
|
||||||
|
|
||||||
def parse_task(path: Path) -> dict:
|
|
||||||
text = path.read_text(encoding="utf-8")
|
|
||||||
lines = text.splitlines()
|
|
||||||
title = lines[0].removeprefix("#").strip() if lines and lines[0].startswith("#") else ""
|
|
||||||
kind, bare = PLAIN_TYPE, title
|
|
||||||
m = TYPE_PREFIX.match(title)
|
|
||||||
if m:
|
|
||||||
kind, bare = m.group(1).strip().lower(), m.group(2).strip()
|
|
||||||
# Мета-строка — первая непустая строка после заголовка (task-format.md).
|
|
||||||
# Поля разделены `·`, порядок свободный: приоритет распознаётся, где бы он ни
|
|
||||||
# стоял, а не только первым. Причина не должна содержать `·` — это разделитель.
|
|
||||||
meta = next((ln.strip() for ln in lines[1:] if ln.strip()), "")
|
|
||||||
priority, reason, tags = "", "", []
|
|
||||||
if META_FIELD.match(meta):
|
|
||||||
for chunk in meta.split("·"):
|
|
||||||
f = META_FIELD.match(chunk.strip())
|
|
||||||
if not f:
|
|
||||||
continue
|
|
||||||
key, value = f.group(1).strip().lower(), f.group(2).strip()
|
|
||||||
if key in ("приоритет", "priority"):
|
|
||||||
priority, _, reason = (p.strip() for p in value.partition("—"))
|
|
||||||
priority = priority.rstrip(".,").lower()
|
|
||||||
elif key in ("теги", "tags"):
|
|
||||||
tags = [t.strip().lower() for t in value.split(",") if t.strip()]
|
|
||||||
return {"title": title, "bare": bare, "type": kind, "priority": priority,
|
|
||||||
"reason": reason, "tags": tags, "path": path}
|
|
||||||
|
|
||||||
|
|
||||||
def tasks_of(root: Path) -> dict[str, dict]:
|
|
||||||
return {p.name: parse_task(p) for p in sorted(root.glob("*.md")) if p.name not in SERVICE}
|
|
||||||
|
|
||||||
|
|
||||||
def touched_map(root: Path) -> dict[str, str]:
|
|
||||||
"""Дата последнего коммита для каждого файла беклога — одним вызовом git.
|
|
||||||
Ключ — имя файла (в каталоге беклога имена уникальны). Нет git / нет
|
|
||||||
истории → пустая карта, вызывающий подставит «—»."""
|
|
||||||
try:
|
|
||||||
out = subprocess.run(["git", "log", "--format=%as", "--name-only", "--", str(root)],
|
|
||||||
capture_output=True, text=True).stdout
|
|
||||||
except FileNotFoundError:
|
|
||||||
return {}
|
|
||||||
dates: dict[str, str] = {}
|
|
||||||
cur = None
|
|
||||||
for line in out.splitlines():
|
|
||||||
if not line.strip():
|
|
||||||
continue
|
|
||||||
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", line):
|
|
||||||
cur = line # лог новейшие сверху → первая дата и есть последняя правка
|
|
||||||
elif cur:
|
|
||||||
dates.setdefault(os.path.basename(line), cur)
|
|
||||||
return dates
|
|
||||||
|
|
||||||
|
|
||||||
def check(root: Path, fix: bool = False) -> int:
|
|
||||||
if fix:
|
|
||||||
for line in apply_fixes(root):
|
|
||||||
print(f"ПОЧИНЕНО {line}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
entries, sections = parse_index(root)
|
|
||||||
tasks = tasks_of(root)
|
|
||||||
known = {s.lower() for s in sections}
|
|
||||||
errors: list[str] = []
|
|
||||||
notes: list[str] = []
|
|
||||||
|
|
||||||
for name, task in tasks.items():
|
|
||||||
entry = entries.get(name)
|
|
||||||
if not entry:
|
|
||||||
errors.append(f"{name}: файла нет в индексе {INDEX}")
|
|
||||||
if not SLUG.fullmatch(name):
|
|
||||||
errors.append(f"{name}: слаг не kebab-case латиницей")
|
|
||||||
if not task["title"]:
|
|
||||||
errors.append(f"{name}: нет заголовка H1")
|
|
||||||
if not task["priority"]:
|
|
||||||
errors.append(f"{name}: нет строки **Приоритет:**")
|
|
||||||
elif task["priority"] not in known:
|
|
||||||
errors.append(f"{name}: приоритет «{task['priority']}» не совпадает"
|
|
||||||
f" ни с одной секцией индекса ({', '.join(sections)})")
|
|
||||||
elif entry and entry["section"] and entry["section"].lower() != task["priority"]:
|
|
||||||
errors.append(f"{name}: приоритет в файле «{task['priority']}»,"
|
|
||||||
f" а в индексе секция «{entry['section']}»")
|
|
||||||
if entry and entry["title"] != task["title"]:
|
|
||||||
errors.append(f"{name}: заголовок разошёлся\n"
|
|
||||||
f" файл: {task['title']}\n"
|
|
||||||
f" индекс: {entry['title']}")
|
|
||||||
if entry and not entry["hook"]:
|
|
||||||
notes.append(f"{name}: строка индекса без хука — по ней не выбрать задачу")
|
|
||||||
if task["type"] not in TYPES and task["type"] != PLAIN_TYPE:
|
|
||||||
notes.append(f"{name}: тип «{task['type']}» вне словаря"
|
|
||||||
f" ({'/'.join(TYPES)} или без префикса)")
|
|
||||||
if "<!-- контекст" in task["path"].read_text(encoding="utf-8"):
|
|
||||||
notes.append(f"{name}: тело не дописано (остался плейсхолдер add)")
|
|
||||||
|
|
||||||
for name, entry in entries.items():
|
|
||||||
if name not in tasks:
|
|
||||||
errors.append(f"{INDEX}:{entry['line']}: ссылка на несуществующий {name}")
|
|
||||||
|
|
||||||
errors += index_lint(root)
|
|
||||||
|
|
||||||
# Кладбище: строки-пункты должны совпадать с форматом (его пишет close).
|
|
||||||
closed = root / CLOSED
|
|
||||||
if closed.is_file():
|
|
||||||
for num, line in enumerate(closed.read_text(encoding="utf-8").splitlines(), 1):
|
|
||||||
if line.startswith("- ") and not CLOSED_ENTRY.match(line):
|
|
||||||
errors.append(f"{CLOSED}:{num}: строка кладбища не по формату"
|
|
||||||
f" «- ГГГГ-ММ-ДД `slug` — …»")
|
|
||||||
|
|
||||||
# Причина у приоритета желательна, но не обязательна. Ругаемся только на
|
|
||||||
# частичное покрытие — это дрейф: у части задач причина есть, у части нет.
|
|
||||||
# Ноль из N — осознанный отказ проекта от причин, не расхождение; горящее на
|
|
||||||
# каждом check замечание агент просто научится игнорировать.
|
|
||||||
with_reason = sum(1 for t in tasks.values() if t["reason"])
|
|
||||||
if 0 < with_reason < len(tasks):
|
|
||||||
notes.append(f"причина у приоритета есть у {with_reason} из {len(tasks)}"
|
|
||||||
f" — либо у всех, либо ни у кого: вперемешку это дрейф")
|
|
||||||
|
|
||||||
print(f"беклог: {root}, задач {len(tasks)}, строк индекса {len(entries)},"
|
|
||||||
f" секций {len(sections)}")
|
|
||||||
health(root, tasks, sections)
|
|
||||||
for e in errors:
|
|
||||||
print(f"ОШИБКА {e}")
|
|
||||||
for n in notes:
|
|
||||||
print(f"замечание {n}")
|
|
||||||
if errors:
|
|
||||||
print(f"\nрасхождений: {len(errors)}")
|
|
||||||
return 1
|
|
||||||
print("\nиндекс согласован" + (f", замечаний: {len(notes)}" if notes else ""))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def health(root: Path, tasks: dict[str, dict], sections: list[str]) -> None:
|
|
||||||
"""Метрики здоровья беклога: размер секций и число давно неподвижных задач.
|
|
||||||
Механизирует правило «беклог гниёт со стороны пополнения» — раньше оно
|
|
||||||
держалось только на дисциплине."""
|
|
||||||
by_section = {s.lower(): 0 for s in sections}
|
|
||||||
for t in tasks.values():
|
|
||||||
if t["priority"] in by_section:
|
|
||||||
by_section[t["priority"]] += 1
|
|
||||||
sizes = ", ".join(f"{s} {by_section[s.lower()]}" for s in sections)
|
|
||||||
print(f" секции: {sizes}")
|
|
||||||
|
|
||||||
dates = touched_map(root)
|
|
||||||
if not dates:
|
|
||||||
return
|
|
||||||
cutoff = (datetime.date.today() - datetime.timedelta(days=STALE_DAYS)).isoformat()
|
|
||||||
stale = sum(1 for t in tasks.values()
|
|
||||||
if (d := dates.get(t["path"].name)) and d < cutoff)
|
|
||||||
if stale:
|
|
||||||
print(f" залежалось (>{STALE_DAYS} дней без правки): {stale}"
|
|
||||||
f" — груминг просрочен, начни с `list --stale`")
|
|
||||||
|
|
||||||
|
|
||||||
def list_tasks(root: Path, args: argparse.Namespace) -> int:
|
|
||||||
tasks = tasks_of(root)
|
|
||||||
_, sections = parse_index(root)
|
|
||||||
order = {s.lower(): i for i, s in enumerate(sections)}
|
|
||||||
rows = [t for t in tasks.values()
|
|
||||||
if (not args.priority or t["priority"] == args.priority.lower())
|
|
||||||
and (not args.type or t["type"] == args.type.lower())
|
|
||||||
and (not args.tag or args.tag.lower() in t["tags"])]
|
|
||||||
|
|
||||||
if args.stale:
|
|
||||||
dates = touched_map(root)
|
|
||||||
for t in rows:
|
|
||||||
t["touched"] = dates.get(t["path"].name, "—")
|
|
||||||
rows.sort(key=lambda t: (t["touched"] == "—", t["touched"]))
|
|
||||||
else:
|
|
||||||
rows.sort(key=lambda t: (order.get(t["priority"], 99), t["path"].name))
|
|
||||||
|
|
||||||
for t in rows:
|
|
||||||
touched = f"{t.get('touched', ''):<11}" if args.stale else ""
|
|
||||||
kind = "" if t["type"] == PLAIN_TYPE else f"[{t['type']}] "
|
|
||||||
print(f"{touched}{t['priority']:<9} {t['path'].stem:<46} {kind}{t['bare']}")
|
|
||||||
print(f"\nвсего: {len(rows)}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# --- Мутации: правят файл и индекс заодно, чтобы их нельзя было рассогласовать ---
|
|
||||||
|
|
||||||
def fail(msg: str) -> int:
|
|
||||||
print(f"ошибка: {msg}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
|
|
||||||
def build_meta(priority: str, reason: str, tags: list[str]) -> str:
|
|
||||||
s = f"**Приоритет:** {priority}"
|
|
||||||
if reason:
|
|
||||||
s += f" — {reason}"
|
|
||||||
if tags:
|
|
||||||
s += " · **Теги:** " + ", ".join(tags)
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def load_index(root: Path) -> list[str]:
|
|
||||||
return (root / INDEX).read_text(encoding="utf-8").splitlines()
|
|
||||||
|
|
||||||
|
|
||||||
def save_index(root: Path, lines: list[str]) -> None:
|
|
||||||
write_atomic(root / INDEX, "\n".join(lines) + "\n")
|
|
||||||
|
|
||||||
|
|
||||||
def section_headers(lines: list[str]) -> list[tuple[int, str]]:
|
|
||||||
return [(i, m.group(1)) for i, l in enumerate(lines) if (m := SECTION.match(l))]
|
|
||||||
|
|
||||||
|
|
||||||
def find_section(lines: list[str], priority: str) -> tuple[int | None, str]:
|
|
||||||
for i, name in section_headers(lines):
|
|
||||||
if name.lower() == priority.lower():
|
|
||||||
return i, name
|
|
||||||
return None, ""
|
|
||||||
|
|
||||||
|
|
||||||
def find_entry_index(lines: list[str], slug: str) -> int | None:
|
|
||||||
for i, l in enumerate(lines):
|
|
||||||
m = INDEX_ENTRY.match(l)
|
|
||||||
if m and m.group(2) == f"{slug}.md":
|
|
||||||
return i
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def insert_entry(lines: list[str], section: str, entry: str) -> None:
|
|
||||||
"""Вставляет строку в конец секции (перед следующим ## или концом файла)."""
|
|
||||||
hi, _ = find_section(lines, section)
|
|
||||||
end = next((j for j in range(hi + 1, len(lines)) if SECTION.match(lines[j])), len(lines))
|
|
||||||
ins = end
|
|
||||||
while ins - 1 > hi and not lines[ins - 1].strip():
|
|
||||||
ins -= 1
|
|
||||||
lines.insert(ins, entry)
|
|
||||||
|
|
||||||
|
|
||||||
def update_priority(path: Path, priority: str, new_reason: str | None) -> bool:
|
|
||||||
"""Хирургически меняет только поле **Приоритет:** в мета-строке файла,
|
|
||||||
сохраняя теги, регистр и любые нераспознанные поля. Возвращает False, если
|
|
||||||
мета-строки нет (тогда правку делать нельзя — вызывающий падает)."""
|
|
||||||
flines = path.read_text(encoding="utf-8").splitlines()
|
|
||||||
mi = next((i for i in range(1, len(flines)) if flines[i].strip()), None)
|
|
||||||
if mi is None or not META_FIELD.match(flines[mi].strip()):
|
|
||||||
return False
|
|
||||||
chunks = flines[mi].split("·")
|
|
||||||
for idx, chunk in enumerate(chunks):
|
|
||||||
f = META_FIELD.match(chunk.strip())
|
|
||||||
if not (f and f.group(1).strip().lower() in ("приоритет", "priority")):
|
|
||||||
continue
|
|
||||||
_, _, old_reason = (p.strip() for p in f.group(2).partition("—"))
|
|
||||||
reason = new_reason if new_reason is not None else old_reason
|
|
||||||
field = f"**Приоритет:** {priority}" + (f" — {reason}" if reason else "")
|
|
||||||
lead = chunk[:len(chunk) - len(chunk.lstrip())]
|
|
||||||
trail = chunk[len(chunk.rstrip()):]
|
|
||||||
chunks[idx] = lead + field + trail
|
|
||||||
write_atomic(path, "\n".join((*flines[:mi], "·".join(chunks), *flines[mi + 1:])) + "\n")
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def apply_fixes(root: Path) -> list[str]:
|
|
||||||
"""Детерминированная починка дрейфа индекса. Чинит только безопасное, где
|
|
||||||
истина однозначно в файле: дубли строк, рассинхрон заголовка, задача не в
|
|
||||||
своей секции, отсутствующая строка. Неоднозначное (ссылка на исчезнувший
|
|
||||||
файл, битые строки, неизвестный приоритет) не трогает — это на суд человека."""
|
|
||||||
fixed: list[str] = []
|
|
||||||
lines = load_index(root)
|
|
||||||
tasks = tasks_of(root)
|
|
||||||
|
|
||||||
# 1. Дубли строк на один файл — оставляем первую.
|
|
||||||
seen: set[str] = set()
|
|
||||||
deduped: list[str] = []
|
|
||||||
for l in lines:
|
|
||||||
m = INDEX_ENTRY.match(l)
|
|
||||||
if m and m.group(2) in seen:
|
|
||||||
fixed.append(f"убран дубль строки {m.group(2)}")
|
|
||||||
continue
|
|
||||||
if m:
|
|
||||||
seen.add(m.group(2))
|
|
||||||
deduped.append(l)
|
|
||||||
lines = deduped
|
|
||||||
|
|
||||||
# 2. Заголовок в индексе разошёлся с H1 — истина в файле, хук сохраняем.
|
|
||||||
for i, l in enumerate(lines):
|
|
||||||
m = INDEX_ENTRY.match(l)
|
|
||||||
if not m:
|
|
||||||
continue
|
|
||||||
task = tasks.get(m.group(2))
|
|
||||||
if task and m.group(1) != task["title"]:
|
|
||||||
hook = (m.group(3) or "").strip()
|
|
||||||
lines[i] = f"- [{task['title']}]({m.group(2)})" + (f" — {hook}" if hook else "")
|
|
||||||
fixed.append(f"заголовок синхронизирован с файлом: {m.group(2)}")
|
|
||||||
|
|
||||||
# 3. Задача не в своей секции / нет строки вовсе.
|
|
||||||
for name, task in tasks.items():
|
|
||||||
if not task["priority"]:
|
|
||||||
continue
|
|
||||||
hi, section = find_section(lines, task["priority"])
|
|
||||||
if hi is None:
|
|
||||||
continue # приоритет не совпадает ни с одной секцией — не наше дело
|
|
||||||
ei = find_entry_index(lines, task["path"].stem)
|
|
||||||
if ei is None:
|
|
||||||
insert_entry(lines, section, f"- [{task['title']}]({name})")
|
|
||||||
fixed.append(f"добавлена строка индекса без хука: {name}")
|
|
||||||
continue
|
|
||||||
cur = next((SECTION.match(lines[j]).group(1)
|
|
||||||
for j in range(ei, -1, -1) if SECTION.match(lines[j])), None)
|
|
||||||
if cur and cur.lower() != section.lower():
|
|
||||||
insert_entry(lines, section, lines.pop(ei))
|
|
||||||
fixed.append(f"перенесена в секцию «{section}»: {name}")
|
|
||||||
|
|
||||||
if fixed:
|
|
||||||
save_index(root, lines)
|
|
||||||
return fixed
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_add(root: Path, a: argparse.Namespace) -> int:
|
|
||||||
for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_line(a.hook, "хук"),
|
|
||||||
bad_line(a.tag, "теги"), bad_reason(a.reason)):
|
|
||||||
if err:
|
|
||||||
return fail(err)
|
|
||||||
if not a.title.strip():
|
|
||||||
return fail("пустой заголовок")
|
|
||||||
path = root / f"{a.slug}.md"
|
|
||||||
if path.exists():
|
|
||||||
return fail(f"{path.name} уже существует — дедуп: допиши в него, а не заводи новый")
|
|
||||||
lines = load_index(root)
|
|
||||||
if find_entry_index(lines, a.slug) is not None:
|
|
||||||
return fail(f"строка индекса для {a.slug} уже есть")
|
|
||||||
hi, section = find_section(lines, a.priority)
|
|
||||||
if hi is None:
|
|
||||||
avail = ", ".join(n for _, n in section_headers(lines))
|
|
||||||
return fail(f"нет секции приоритета «{a.priority}» (есть: {avail})")
|
|
||||||
kind = a.type.strip().lower() if a.type else ""
|
|
||||||
title_full = f"[{kind}] {a.title}" if kind else a.title
|
|
||||||
tags = [t.strip() for t in (a.tag or "").split(",") if t.strip()]
|
|
||||||
meta = build_meta(section, a.reason or "", tags)
|
|
||||||
body = "<!-- контекст, принятые решения, шаги, ссылки на спеки/ADR -->"
|
|
||||||
write_atomic(path, f"# {title_full}\n\n{meta}\n\n{body}\n")
|
|
||||||
entry = f"- [{title_full}]({a.slug}.md)" + (f" — {a.hook}" if a.hook else "")
|
|
||||||
insert_entry(lines, section, entry)
|
|
||||||
save_index(root, lines)
|
|
||||||
print(f"создано: {a.slug}.md в секции «{section}»; допиши тело редактором")
|
|
||||||
if not a.hook:
|
|
||||||
print(f" без хука — задай: backlog.py edit {a.slug} --hook …")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_edit(root: Path, a: argparse.Namespace) -> int:
|
|
||||||
for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_line(a.hook, "хук")):
|
|
||||||
if err:
|
|
||||||
return fail(err)
|
|
||||||
if a.title is None and a.hook is None and a.type is None:
|
|
||||||
return fail("нечего менять: дай --title, --hook или --type")
|
|
||||||
path = root / f"{a.slug}.md"
|
|
||||||
if not path.exists():
|
|
||||||
return fail(f"{a.slug}.md не найден")
|
|
||||||
lines = load_index(root)
|
|
||||||
ei = find_entry_index(lines, a.slug)
|
|
||||||
if ei is None:
|
|
||||||
return fail(f"строки индекса для {a.slug} нет")
|
|
||||||
task = parse_task(path)
|
|
||||||
if a.title is not None and not a.title.strip():
|
|
||||||
return fail("пустой заголовок")
|
|
||||||
bare = a.title if a.title is not None else task["bare"]
|
|
||||||
kind = task["type"] if a.type is None else a.type.strip().lower()
|
|
||||||
prefix = "" if kind in ("", PLAIN_TYPE) else f"[{kind}] "
|
|
||||||
h1 = f"{prefix}{bare}"
|
|
||||||
flines = path.read_text(encoding="utf-8").splitlines()
|
|
||||||
if not flines or not flines[0].startswith("#"):
|
|
||||||
return fail(f"{a.slug}.md без заголовка H1 — прогони check")
|
|
||||||
flines[0] = f"# {h1}"
|
|
||||||
write_atomic(path, "\n".join(flines) + "\n")
|
|
||||||
m = INDEX_ENTRY.match(lines[ei])
|
|
||||||
hook = a.hook if a.hook is not None else (m.group(3) or "").strip()
|
|
||||||
lines[ei] = f"- [{h1}]({a.slug}.md)" + (f" — {hook}" if hook else "")
|
|
||||||
save_index(root, lines)
|
|
||||||
print(f"{a.slug}: обновлено (заголовок/хук/тип)")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_move(root: Path, a: argparse.Namespace) -> int:
|
|
||||||
for err in (bad_slug(a.slug), bad_reason(a.reason)):
|
|
||||||
if err:
|
|
||||||
return fail(err)
|
|
||||||
path = root / f"{a.slug}.md"
|
|
||||||
if not path.exists():
|
|
||||||
return fail(f"{a.slug}.md не найден")
|
|
||||||
lines = load_index(root)
|
|
||||||
ei = find_entry_index(lines, a.slug)
|
|
||||||
if ei is None:
|
|
||||||
return fail(f"строки индекса для {a.slug} нет")
|
|
||||||
hi, section = find_section(lines, a.priority)
|
|
||||||
if hi is None:
|
|
||||||
avail = ", ".join(n for _, n in section_headers(lines))
|
|
||||||
return fail(f"нет секции приоритета «{a.priority}» (есть: {avail})")
|
|
||||||
if not update_priority(path, section, a.reason):
|
|
||||||
return fail(f"{a.slug}.md без мета-строки **Приоритет:** — прогони check и почини")
|
|
||||||
entry = lines.pop(ei)
|
|
||||||
insert_entry(lines, section, entry)
|
|
||||||
save_index(root, lines)
|
|
||||||
print(f"{a.slug}: перенесено в «{section}»")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_close(root: Path, a: argparse.Namespace) -> int:
|
|
||||||
for err in (bad_slug(a.slug), bad_reason(a.reason)):
|
|
||||||
if err:
|
|
||||||
return fail(err)
|
|
||||||
path = root / f"{a.slug}.md"
|
|
||||||
if not path.exists():
|
|
||||||
return fail(f"{a.slug}.md не найден")
|
|
||||||
lines = load_index(root)
|
|
||||||
ei = find_entry_index(lines, a.slug)
|
|
||||||
if ei is None:
|
|
||||||
return fail(f"строки индекса для {a.slug} нет")
|
|
||||||
task = parse_task(path)
|
|
||||||
if a.reason:
|
|
||||||
reason = a.reason.rstrip()
|
|
||||||
dot = "" if reason.endswith((".", "!", "?")) else "."
|
|
||||||
date = datetime.date.today().isoformat()
|
|
||||||
bullet = (f"- {date} `{a.slug}` — {task['title']}. Причина: {reason}{dot}"
|
|
||||||
f" Был приоритет: {task['priority'] or '—'}.")
|
|
||||||
closed = root / CLOSED
|
|
||||||
prev = closed.read_text(encoding="utf-8") if closed.exists() else "# Кладбище беклога\n"
|
|
||||||
if not prev.endswith("\n"):
|
|
||||||
prev += "\n"
|
|
||||||
write_atomic(closed, prev + bullet + "\n")
|
|
||||||
# Порядок: индекс без строки → потом unlink. Обратный порядок оставил бы в
|
|
||||||
# индексе ссылку в никуда, если бы unlink упал.
|
|
||||||
lines.pop(ei)
|
|
||||||
save_index(root, lines)
|
|
||||||
path.unlink()
|
|
||||||
print(f"{a.slug}: {'на кладбище + удалено' if a.reason else 'удалено (реализовано)'}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_init(root: Path, a: argparse.Namespace) -> int:
|
|
||||||
if not dir_within_cwd(root):
|
|
||||||
return fail(f"--dir вне рабочего каталога: {root}")
|
|
||||||
index = root / INDEX
|
|
||||||
if index.exists():
|
|
||||||
return fail(f"{index} уже есть — беклог заведён")
|
|
||||||
sections, seen = [], set()
|
|
||||||
for s in (s.strip() for s in a.sections.split(",")):
|
|
||||||
if s and s.lower() not in seen:
|
|
||||||
sections.append(s)
|
|
||||||
seen.add(s.lower())
|
|
||||||
if not sections:
|
|
||||||
return fail("пустой список секций")
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
|
||||||
preamble = ("# Беклог\n\n"
|
|
||||||
"Одна задача = один файл `<slug>.md` + строка в этом индексе.\n"
|
|
||||||
"Приоритет — грубая оценка «ценность / стоимость». Спекулятивные\n"
|
|
||||||
"задачи помечены `[idea]` в заголовке. Ведётся скиллом `backlog`.\n\n")
|
|
||||||
write_atomic(index, preamble + "".join(f"## {s}\n\n" for s in sections))
|
|
||||||
closed = root / CLOSED
|
|
||||||
if not closed.exists():
|
|
||||||
write_atomic(closed,
|
|
||||||
"# Кладбище беклога\n\n"
|
|
||||||
"Задачи, покинувшие беклог без реализации. Пишется `backlog.py close`.\n\n"
|
|
||||||
"<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Был приоритет: … -->\n")
|
|
||||||
print(f"беклог заведён: {root} (секции: {', '.join(sections)})")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
ap = argparse.ArgumentParser(prog="backlog.py")
|
|
||||||
sub = ap.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
p = sub.add_parser("check", help="согласованность файлов и индекса")
|
|
||||||
p.add_argument("--dir")
|
|
||||||
p.add_argument("--fix", action="store_true",
|
|
||||||
help="починить безопасный дрейф (секция, заголовок, дубли)")
|
|
||||||
|
|
||||||
p = sub.add_parser("list", help="список задач")
|
|
||||||
p.add_argument("--dir")
|
|
||||||
p.add_argument("--stale", action="store_true")
|
|
||||||
p.add_argument("--priority")
|
|
||||||
p.add_argument("--type")
|
|
||||||
p.add_argument("--tag")
|
|
||||||
|
|
||||||
p = sub.add_parser("add", help="создать задачу")
|
|
||||||
p.add_argument("--dir")
|
|
||||||
p.add_argument("--slug", required=True)
|
|
||||||
p.add_argument("--title", required=True)
|
|
||||||
p.add_argument("--priority", required=True)
|
|
||||||
p.add_argument("--type", choices=TYPES)
|
|
||||||
p.add_argument("--hook")
|
|
||||||
p.add_argument("--reason")
|
|
||||||
p.add_argument("--tag")
|
|
||||||
|
|
||||||
p = sub.add_parser("edit", help="сменить заголовок/хук/тип")
|
|
||||||
p.add_argument("slug")
|
|
||||||
p.add_argument("--title")
|
|
||||||
p.add_argument("--hook")
|
|
||||||
p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE))
|
|
||||||
p.add_argument("--dir")
|
|
||||||
|
|
||||||
p = sub.add_parser("move", help="перенести в другую секцию приоритета")
|
|
||||||
p.add_argument("slug")
|
|
||||||
p.add_argument("--priority", required=True)
|
|
||||||
p.add_argument("--reason")
|
|
||||||
p.add_argument("--dir")
|
|
||||||
|
|
||||||
p = sub.add_parser("close", help="закрыть задачу (кладбище или удаление)")
|
|
||||||
p.add_argument("slug")
|
|
||||||
g = p.add_mutually_exclusive_group(required=True)
|
|
||||||
g.add_argument("--reason", help="причина отказа → строка на кладбище")
|
|
||||||
g.add_argument("--implemented", action="store_true", help="реализовано → просто удалить")
|
|
||||||
p.add_argument("--dir")
|
|
||||||
|
|
||||||
p = sub.add_parser("init", help="завести пустой беклог")
|
|
||||||
p.add_argument("--dir")
|
|
||||||
p.add_argument("--sections", default="высокий,средний,низкий")
|
|
||||||
|
|
||||||
a = ap.parse_args()
|
|
||||||
if a.command == "init":
|
|
||||||
return cmd_init(Path(a.dir or "docs/backlog"), a)
|
|
||||||
root = resolve_dir(a.dir)
|
|
||||||
dispatch = {
|
|
||||||
"check": lambda: check(root, a.fix),
|
|
||||||
"list": lambda: list_tasks(root, a),
|
|
||||||
"add": lambda: cmd_add(root, a),
|
|
||||||
"edit": lambda: cmd_edit(root, a),
|
|
||||||
"move": lambda: cmd_move(root, a),
|
|
||||||
"close": lambda: cmd_close(root, a),
|
|
||||||
}
|
|
||||||
return dispatch[a.command]()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -17,9 +17,6 @@ package = false
|
|||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py312"
|
target-version = "py312"
|
||||||
line-length = 88
|
line-length = 88
|
||||||
# backlog.py заморожен: плагин помечен УСТАРЕЛ и живёт до перевода последнего
|
|
||||||
# проекта, после чего удаляется целиком. Правки в него — риск без выгоды.
|
|
||||||
exclude = ["av-dev-backlog"]
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = [
|
select = [
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
|
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
|
||||||
|
|
||||||
SKIP_DIRS = {".git", ".venv", "node_modules", "__pycache__", "av-dev-backlog"}
|
SKIP_DIRS = {".git", ".venv", "node_modules", "__pycache__"}
|
||||||
|
|
||||||
# Идентификатор — только буквы, цифры и дефис. Строгость намеренная: она же
|
# Идентификатор — только буквы, цифры и дефис. Строгость намеренная: она же
|
||||||
# отличает **настоящий** маркер от примера в документации об этом механизме.
|
# отличает **настоящий** маркер от примера в документации об этом механизме.
|
||||||
|
|||||||
Reference in New Issue
Block a user