From b99c0c23668f9b619032b8c269c461b1e4bf23f3 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Tue, 4 Aug 2026 16:49:58 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BA=D0=B0=D0=BD=D0=BE=D0=BD=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=B8=203:=20=D1=80=D0=BE=D0=B0=D0=B4=D0=BC?= =?UTF-8?q?=D0=B0=D0=BF,=20=D1=80=D0=BE=D0=B4=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D1=8B,=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три изменения одной версией, потому что все три про одно — можно ли оценить задачу, не открывая код. PLAN.md → ROADMAP.md. Слово «план» значило в репозитории три разных вещи: оглавление целей, план реализации внутри задачи и PLAN.json разовой адаптации. Переименовано целиком — ключ конфига tasks.plan → tasks.roadmap, --index roadmap, --roadmap-sections, --roadmap. Старый ключ в docs/.pm.json не игнорируется молча: скрипт останавливается кодом 3 и называет переименование, иначе проект искал бы опечатку там, где на самом деле версия канона. Род работы — тег kind:feature|fix|chore|research, вторая ось поверх типа записи. В один префикс их не свести: идея бывает про функцию, эпик функцией и является. Дом — тег, потому что теги здесь единственный механизм разметки, а list --kind работает даром; цена принята — в строку индекса род не попадает. Словарь закрыт, иначе он разъедется на bug/bugfix/fix/defect. Отдельно легализован chore: у него «что станет наблюдаемо иначе» отвечается разработчику, а раньше такие задачи либо не заводились, либо придумывали себе пользовательскую пользу — и это второе хуже, оно проходит проверку. Раздел «Затрагивает» — границы, которых изменение касается: эндпоинт, таблица и миграция, формат на диске, публичный тип пакета. Без него задача оценивается по объёму текста, а не по объёму поверхности. Механизируется только наличие непустого раздела: полноту перечня машина не видит. Род и границы требуются к взятию в спринт, а не к заведению — тот же приём, что уже работает для критериев приёмки, и по той же причине. check о пропаже только напоминает: иначе два живых проекта покраснели бы на 98 задачах, заведённых до этого решения. Плюс правила языка задач: англицизм, у которого есть русское слово, заменяется; термин не из паспорта, архитектуры или конвенций вводится строкой или не употребляется; задача, которую не удаётся сказать просто, чаще всего не одна задача. Co-Authored-By: Claude Opus 5 (1M context) --- av-dev-pm/skills/canon/references/canon.md | 26 +- .../skills/canon/references/changelog.md | 41 +++ .../skills/canon/references/skeletons.md | 2 +- av-dev-pm/skills/canon/scripts/docs.py | 6 +- av-dev-pm/skills/init/SKILL.md | 2 +- av-dev-pm/skills/session/SKILL.md | 2 +- .../skills/session/references/cadence.md | 28 +- av-dev-pm/skills/tasks/SKILL.md | 146 +++++++++-- av-dev-pm/skills/tasks/references/adopt.md | 8 +- .../skills/tasks/references/from-review.md | 7 +- av-dev-pm/skills/tasks/references/split.md | 2 +- .../skills/tasks/references/task-format.md | 73 +++++- av-dev-pm/skills/tasks/scripts/tasks.py | 245 +++++++++++++----- 13 files changed, 452 insertions(+), 136 deletions(-) diff --git a/av-dev-pm/skills/canon/references/canon.md b/av-dev-pm/skills/canon/references/canon.md index f553d23..4b835ca 100644 --- a/av-dev-pm/skills/canon/references/canon.md +++ b/av-dev-pm/skills/canon/references/canon.md @@ -1,6 +1,6 @@ # Канон документов проекта -**Версия 2.** +**Версия 3.** Это **единственный дом определения канона**. Скиллы `init`, `canon` и `docs` читают его, а не пересказывают: три описания одной раскладки разъедутся, и @@ -39,7 +39,7 @@ docs/ template.md ADR-ГГГГ-ММ-ДД-slug.md review.md настройка конвейера под проект + журнал дефектов - tasks/ скилл tasks: items/, PLAN.md, BACKLOG.md, + tasks/ скилл tasks: items/, ROADMAP.md, BACKLOG.md, SPRINT.md, REJECTED.md openspec/ config.yaml только нужды генерации артефактов + ссылки @@ -196,6 +196,22 @@ kebab-case. выборка по пометке. Пойманные с оракулом — лучшая опора для прохода: проектные, воспроизводимые, однажды оказавшиеся правдой. +### `tasks/` + +Раскладку, форму записи и команды держит скилл `tasks` — канон фиксирует только +имена файлов (`items/`, `ROADMAP.md`, `BACKLOG.md`, `SPRINT.md`, `REJECTED.md`) и +два требования к самой записи, потому что от них зависит, можно ли задачу +оценить: + +- **род работы** тегом `kind:<род>` из закрытого словаря `feature` | `fix` | + `chore` | `research` — у задачи обязателен, у цели запрещён; +- **раздел «Затрагивает»** в теле задачи — границы, которых изменение касается + (эндпоинт, таблица и миграция, формат на диске, публичный тип пакета). + +Оба требуются **к взятию в спринт**, а не к заведению: беклог пополняется чаще, +чем разбирается, и требование на входе выгоняло бы в заметки то, что должно +лежать задачей. + ### `CLAUDE.md` Что это и стек; **инварианты с severity рядом с формулировкой** — по ним проходы @@ -234,7 +250,7 @@ kebab-case. | почему решено так | `adr/`, источник — архивный `design.md` | | граница домена, «чем не является» | `passport.md` | | инвариант и его severity | `CLAUDE.md` | -| порядок работ и его обоснование | `docs/tasks/PLAN.md` | +| порядок работ и его обоснование | `docs/tasks/ROADMAP.md` | | измеренное число | `research/` | | настройка с числовым значением | `database.md` | | периметр и модель угроз | `security.md` | @@ -267,8 +283,8 @@ kebab-case. | --- | --- | | `docs/review-brief.md` | документы канона и есть бриф; остаток — в `review.md` | | `docs/specs/` | `openspec/specs/` (поведение) и `architecture.md` (обзор) | -| `docs/drafts/` | идея → задача `[idea]`; отказ → ADR; порядок → `PLAN.md`; размышление → `opsx:explore` | -| `docs/plan.md` | `docs/tasks/PLAN.md` | +| `docs/drafts/` | идея → задача `[idea]`; отказ → ADR; порядок → `ROADMAP.md`; размышление → `opsx:explore` | +| `docs/plan.md` | `docs/tasks/ROADMAP.md` | | `BRIEF.md` | `passport.md` | | `docs/backlog/` | `docs/tasks/` | | `docs/review-journal.md`, `docs/review/journal.md` | `docs/review.md` | diff --git a/av-dev-pm/skills/canon/references/changelog.md b/av-dev-pm/skills/canon/references/changelog.md index 8e80cbe..99d552c 100644 --- a/av-dev-pm/skills/canon/references/changelog.md +++ b/av-dev-pm/skills/canon/references/changelog.md @@ -13,6 +13,47 @@ upgrade` идёт по записям снизу вверх от версии п --- +## Версия 3 — 2026-08-04 + +Оглавление целей переименовано, у задач появился род работы и раздел +«Затрагивает». Раскладка меняется в одном файле, но переименование тянет за +собой ссылки, поэтому шаги делаются одним заходом. + +**Что добавилось:** + +1. **Род работы** — тег `kind:<род>` в мете задачи, словарь закрыт: + `feature` | `fix` | `chore` | `research`. Обязателен у задачи, у цели + запрещён. `sprint take` без него отказывает, `check` о пропаже напоминает + замечанием. Определение — [canon.md](canon.md), раздел `tasks/`; смысл и + причина, почему тегом, — в SKILL.md скилла `tasks`, раздел «Род работы». +2. **Раздел «Затрагивает»** в теле задачи — перечень границ, которых изменение + касается (эндпоинт, таблица и миграция, формат на диске, публичный тип). Как + и критерии приёмки, требуется к взятию в спринт, а не к заведению. + +**Что переехало:** `docs/tasks/PLAN.md` → `docs/tasks/ROADMAP.md`. Вместе с +файлом переименован ключ конфига `tasks.plan` → `tasks.roadmap` и токены +команд: `--index plan` → `--index roadmap`, `init --plan-sections` → +`--roadmap-sections`, `init --plan` → `--roadmap`. Старый ключ в +`docs/.pm.json` не игнорируется молча — `tasks.py` останавливается и называет +переименование. + +**Что удалено:** ничего. + +**Что сделать проекту:** + +1. `git mv docs/tasks/PLAN.md docs/tasks/ROADMAP.md`. +2. Починить ссылки на прежнее имя: `grep -rn 'PLAN\.md' docs/ CLAUDE.md` — + заголовок самого файла («# План» → «# Роадмап»), строка в `docs/tasks/BACKLOG.md`, + упоминания в `docs/passport.md` и в телах задач. +3. `docs/.pm.json`: ключ `tasks.plan`, если он там был, — в `tasks.roadmap`. +4. Проставить род работы живым задачам: `python3 tasks.py check --dir docs/tasks` + перечислит те, у кого его нет. Задним числом весь беклог не переоформляется — + род нужен к взятию, так что порядок такой: сперва то, что берётся в ближайший + спринт, остальное по ходу переоценки. +5. Дописать раздел «Затрагивает» — тем же порядком и по той же причине: сперва + набор спринта, остальное по мере того, как задача попадает в работу. +6. `docs/.pm.json`: `"canon": 3`. + ## Версия 2 — 2026-08-03 Шапка записи ADR — мета-блоком общей формы, и у статуса появился объявленный diff --git a/av-dev-pm/skills/canon/references/skeletons.md b/av-dev-pm/skills/canon/references/skeletons.md index 0c81588..2a93825 100644 --- a/av-dev-pm/skills/canon/references/skeletons.md +++ b/av-dev-pm/skills/canon/references/skeletons.md @@ -29,7 +29,7 @@ # Паспорт проекта Зачем это и для кого. [architecture.md](architecture.md) отвечает «как -устроено», [tasks/PLAN.md](tasks/PLAN.md) — «в каком порядке», паспорт — +устроено», [tasks/ROADMAP.md](tasks/ROADMAP.md) — «в каком порядке», паспорт — «зачем и для кого». ## Цель diff --git a/av-dev-pm/skills/canon/scripts/docs.py b/av-dev-pm/skills/canon/scripts/docs.py index 2cb51a4..a658002 100644 --- a/av-dev-pm/skills/canon/scripts/docs.py +++ b/av-dev-pm/skills/canon/scripts/docs.py @@ -25,7 +25,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import NoReturn -CANON_VERSION = 2 +CANON_VERSION = 3 OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4 @@ -65,12 +65,12 @@ ALLOWED_DIRS = {"conventions", "research", "adr", "tasks"} RETIRED = { "review-brief.md": "документы канона и есть бриф; остаток — в review.md", "review-journal.md": "→ docs/review.md", - "plan.md": "→ docs/tasks/PLAN.md", + "plan.md": "→ docs/tasks/ROADMAP.md", "conventions.md": "→ docs/conventions/", "local-research.md": "→ docs/research/", "research.md": "→ docs/research/", "specs": "поведение → openspec/specs/, обзор → docs/architecture.md", - "drafts": "идея → задача [idea], отказ → ADR, порядок → PLAN.md", + "drafts": "идея → задача [idea], отказ → ADR, порядок → ROADMAP.md", "backlog": "→ docs/tasks/", "review": "→ docs/review.md", } diff --git a/av-dev-pm/skills/init/SKILL.md b/av-dev-pm/skills/init/SKILL.md index 103ee79..b57803f 100644 --- a/av-dev-pm/skills/init/SKILL.md +++ b/av-dev-pm/skills/init/SKILL.md @@ -26,7 +26,7 @@ description: "Завести новый проект — сессия вопро | `passport.md` | `architecture.md` | | `CLAUDE.md` | `database.md` | | `security.md` | `conventions/` | -| `docs/tasks/PLAN.md` — первые цели | `research/`, `adr/` | +| `docs/tasks/ROADMAP.md` — первые цели | `research/`, `adr/` | | `docs/.pm.json` | `review.md` — журнал пуст, настройка появится с первым ревью | Честная строка информативна, а не «TBD»: «архитектуры пока нет: кода нет, diff --git a/av-dev-pm/skills/session/SKILL.md b/av-dev-pm/skills/session/SKILL.md index 2e05b80..b5def84 100644 --- a/av-dev-pm/skills/session/SKILL.md +++ b/av-dev-pm/skills/session/SKILL.md @@ -38,7 +38,7 @@ description: "Ритуал между спринтами и ведение са ## Единицы - **Цель** — то, ради чего набирается спринт. Файл `[goal]`, перечисленный в - `PLAN.md`. Цель постоянна: живёт, пока живёт направление. + `ROADMAP.md`. Цель постоянна: живёт, пока живёт направление. - **Задача** — то, что мерджится целиком и даёт видимую пользу. - **Вопрос** — решение человека. Не останавливает начатую работу, но **блокирует взятие** задачи в спринт. Живёт внутри файла задачи разделом «Вопросы» и тегом diff --git a/av-dev-pm/skills/session/references/cadence.md b/av-dev-pm/skills/session/references/cadence.md index b74270a..de7a4a0 100644 --- a/av-dev-pm/skills/session/references/cadence.md +++ b/av-dev-pm/skills/session/references/cadence.md @@ -105,8 +105,11 @@ 4. **Пере-кластеризуй по общей причине.** Несколько задач, оказавшихся симптомами одного дефекта, сливаются в одну — это находка, которую интейк дать не мог. 5. **Гигиена полей** — протухшее «зачем», вопрос в прозе, снятый ответ, свойство - репозитория в рамках, предписание процесса в теле. Список и правила — в - скилле `tasks`. + репозитория в рамках, предписание процесса в теле, род работы, разошедшийся с + задачей, границы вместо реализации в разделе «Затрагивает». Список и правила — + в скилле `tasks`. **Переоценка — то самое место, где беклог добирает род + работы и границы:** требовать их на входе значило бы выгонять в заметки то, + что должно лежать задачей, а к взятию в спринт они уже обязательны. Затем — то, что решает пользователь: @@ -170,21 +173,24 @@ ## Шаг 4. Выбор цели и набор спринта -1. **Покажи состояние целей**: «порядок» `PLAN.md` с обоснованием очереди, темы, и +1. **Покажи состояние целей**: «порядок» `ROADMAP.md` с обоснованием очереди, темы, и по каждой цели-кандидату — сколько под ней задач без открытых вопросов (`list --goal <слаг>`). Цель без готовых задач набором не станет: её сперва надо декомпозировать. 2. **Цель называет человек.** Это продуктовое решение, а не механика: агент предлагает и объясняет, но не выбирает. 3. **Набор собирает агент** — `sprint start --goal <слаг>`, затем `sprint take - …`. Скрипт не даст взять чужую цель, идею, эпик, задачу с открытым вопросом - или без критериев приёмки. + …`. Скрипт не даст взять чужую цель, идею, эпик, задачу с открытым вопросом, + без критериев приёмки, без рода работы или без раздела «Затрагивает». 4. **Набор показывается человеку до старта работ.** Показ — это и есть момент - заморозки: после него набор не двигается. -5. Задача, которой для взятия не хватает только критериев приёмки, дописывается - здесь же — 2–5 утверждений, у каждого назван оракул (меньше двух `sprint - take` не примет). Но если для критериев нужен ответ человека, это вопрос, и - задача в набор не идёт. + заморозки: после него набор не двигается. **В показе называется состав по + роду работы** — три `fix` и ни одной `feature` под целью развития это + разговор про цель, а не про набор, и увидеть его надо до заморозки, а не в + докладе по итогам. +5. Задача, которой для взятия не хватает только критериев приёмки, границ или + рода, дописывается здесь же — 2–5 утверждений с оракулами, перечень + затрагиваемых границ, `--kind`. Но если для этого нужен ответ человека, это + вопрос, и задача в набор не идёт. **Размер — ориентир, а не закон:** 5–8 задач. Можно взять больше, можно меньше — набор под цель важнее круглого числа; одна крупная задача спринтом тоже бывает. @@ -196,7 +202,7 @@ - Разбор процесса: что записано и куда. - Изменения списком: удалено как реализованное (со ссылками), ушло без реализации (с причинами), понижено до идей, слито, сменило цель. -- Новый спринт: цель, набор со слагами, дата. +- Новый спринт: цель, набор со слагами, дата, состав по роду работы. - **Границы покрытия**: сколько задач не трогали и какие именно секции, теги или цели остались — иначе доклад читается как «беклог разобран». - `tasks.py check` после правок — результат строкой. diff --git a/av-dev-pm/skills/tasks/SKILL.md b/av-dev-pm/skills/tasks/SKILL.md index d539a7c..df95b8d 100644 --- a/av-dev-pm/skills/tasks/SKILL.md +++ b/av-dev-pm/skills/tasks/SKILL.md @@ -48,13 +48,13 @@ description: Ведение задач и целей как каталога mar ``` docs/tasks/ items/ задачи и цели файлами, .md, слаги английские - PLAN.md оглавление целей: порядок (значим) и темы (без порядка) + ROADMAP.md оглавление целей: порядок (значим) и темы (без порядка) BACKLOG.md что можно взять — только задачи, целей здесь нет SPRINT.md текущий спринт: цель, набор, дата REJECTED.md ушедшее БЕЗ реализации, с причиной и датой ``` -Правило, снимающее путаницу: **`BACKLOG.md` — то, что берут; `PLAN.md` — то, +Правило, снимающее путаницу: **`BACKLOG.md` — то, что берут; `ROADMAP.md` — то, подо что берут.** Цель в спринт взять нельзя, поэтому в списке берущихся ей не место. @@ -82,7 +82,7 @@ docs/tasks/ ```mermaid stateDiagram-v2 state "BACKLOG.md — что берут" as B - state "PLAN.md — подо что берут" as P + state "ROADMAP.md — подо что берут" as P state "SPRINT.md — набор спринта" as S state "REJECTED.md — ушла без реализации" as R state "записи нет — реализована" as D @@ -110,7 +110,7 @@ stateDiagram-v2 ## Цели -**Цель — такой же файл в `items/`, тип `[goal]`**, перечисленный в `PLAN.md`: +**Цель — такой же файл в `items/`, тип `[goal]`**, перечисленный в `ROADMAP.md`: либо цель из секции **порядок** — там очередь значима и обоснована прозой, — либо **тематическая**, в порядок не встающая («прочность слияния», «журнал и пересборка»). Без второй секции половина целей была бы нигде не перечислена: @@ -134,6 +134,78 @@ stateDiagram-v2 разбирают, и он исчезает. Два срока жизни под одним словом разъезжаются, поэтому слова два. +## Род работы + +**Тип записи и род работы — две оси, и путать их нельзя.** Тип отвечает «что это +за запись» (цель, идея, эпик, задача), род — «какого рода работа»: `feature`, +`fix`, `chore`, `research`. Одним значением на оба вопроса не ответить: идея +бывает *про* функцию, эпик функцией *и является*. + +- **`feature`** — снаружи появляется или меняется то, чего раньше не было. +- **`fix`** — поведение расходится с заявленным, и расхождение воспроизводится. + Не воспроизводится — это `research`, а не `fix`. +- **`chore`** — обслуживание: зависимости, сборка, перенос, чистка. Наблюдаемое + поведение не меняется, и в этом всё дело: **у `chore` тест готовности слабее + честно**, а не молча. «Что станет наблюдаемо иначе» здесь отвечается + разработчику («перестанет собираться два раза», «уедет последний вызов + устаревшего API»), а не пользователю. Пока рода не было, такие задачи либо не + заводились, либо формулировались как выдуманная польза. +- **`research`** — исход работы знание, а не изменение системы: ответ на вопрос, + замер, разведка. Приёмка — записанный ответ (`docs/research/`, ADR, тело + задачи), а не изменённый код. + +Дом рода — **тег `kind:<род>`**, а не префикс заголовка и не поле меты: теги +здесь единственный механизм разметки, и `list --kind fix` работает даром. Цена +известна: в строку индекса род не попадает (индексы производны), и «в наборе одни +починки» видно командой, а не глазами по `SPRINT.md`. + +Словарь **закрыт**. Открытый разъедется на синонимах — `bug`, `bugfix`, `fix`, +`defect`, — и отбор по роду перестанет отвечать на свой единственный вопрос. Ни +один род не подходит — это сигнал, что в задаче их два и её надо разделить. + +**Род обязателен у задачи, у цели запрещён, у идеи и эпика необязателен** — идея +получает его, когда становится задачей, а эпик исчезает после разбора, и род +несут его части. Требуется он там, где по нему принимают решение: `sprint take` +без рода откажет. `check` о пропаже только **напоминает** — беклог, заведённый до +появления рода, законен, и переоформлять его «заодно» здесь не просят. + +**Род не выбирает профиль ревью и вообще ничего не предписывает пайплайну.** +Профиль выбирается по факту изменения, а не по роду задачи: `chore` бывает +миграцией схемы, `fix` — правкой публичного контракта. Правило «предписание +процесса в теле задачи снимается» родом не отменяется, а подтверждается: он +описывает работу, а не то, как её проверять. + +## Как написана задача + +Два требования к тексту, и оба про то, чтобы задачу можно было **оценить, не +открывая код**. + +**Функции и границы, а не намерения.** Задача называет, что система начнёт +делать, и какие границы это трогает: эндпоинт или команду, таблицу и миграцию, +формат на диске, публичный тип пакета, внешний сервис. Перечень живёт разделом +«Затрагивает» (форма — [references/task-format.md](references/task-format.md)) и +требуется к взятию в спринт. Без него задача оценивается по объёму текста, а не +по объёму поверхности, — и оценка систематически занижена ровно там, где текст +короткий, а границ много. Названы **границы**, а не то, как они изменятся: план +реализации живёт в предложении об изменении, а не в задаче. + +**Предметно, но без усложнения.** Текст задачи читает человек, который решает, +брать её или нет, и делает это по строке индекса и одному экрану тела. + +- **англицизм, у которого есть русское слово, — заменяется**: не «зафиксить + флоу», а «починить порядок доставки»; не «отрефакторить», а «убрать второй + путь приёма». Английские остаются там, где они и есть имя вещи: слаг, + `capability`, имя пакета, команда, тип в коде. +- **термин, которого нет в паспорте, архитектуре или конвенциях проекта, вводится + одной строкой** или не употребляется. Свой словарь у задачи — самый дешёвый + способ сделать беклог нечитаемым для того, кто вернётся к нему через квартал. +- **сложность формулировки — не признак сложности работы.** Задачу, которую не + удаётся сказать просто, чаще всего не удаётся и оценить: это либо две задачи, + либо идея. + +Эти правила — про **язык**, а не про объём: короткая задача без границ хуже +длинной с ними. + ## Инструмент (`tasks.py`) Пусть `tk="$CLAUDE_PLUGIN_ROOT/skills/tasks/scripts/tasks.py"`, а `D` — @@ -143,15 +215,15 @@ stateDiagram-v2 ``` python3 $tk check --dir D # согласованность индексов + здоровье python3 $tk check --dir D --fix # + починить дрейф (секция, заголовок, дубли, «зачем», форма меты) -python3 $tk list --dir D [--stale] [--section S] [--type T] [--tag a,b] [--goal S] [--index …] [--questions] -python3 $tk add --dir D --slug S --title T [--type goal|idea|epic] [--section S] [--goal G] [--why «зачем»] [--tag a,b] -python3 $tk edit S --dir D [--title T] [--why «зачем»] [--type T] [--goal G] [--add-tag a,b] [--rm-tag c] +python3 $tk list --dir D [--stale] [--section S] [--type T] [--kind K] [--tag a,b] [--goal S] [--index …] [--questions] +python3 $tk add --dir D --slug S --title T [--type goal|idea|epic] [--section S] [--goal G] [--kind K] [--why «зачем»] [--tag a,b] +python3 $tk edit S --dir D [--title T] [--why «зачем»] [--type T] [--goal G] [--kind K] [--add-tag a,b] [--rm-tag c] python3 $tk move S --dir D --section S [--reason R] [--after S | --first] python3 $tk close S --dir D --reason R # в REJECTED.md + удалить (ушла без реализации) python3 $tk close S --dir D --implemented # просто удалить (реализована и закоммичена) python3 $tk reopen S --dir D --reason R # вернуть закрытую: приёмка не сошлась python3 $tk sprint start --goal S --dir D | take S… | drop S… --reason R | close [--dissolve --reason R] -python3 $tk init --dir D [--sections …] [--plan-sections …] [--items …] [--backlog …] … +python3 $tk init --dir D [--sections …] [--roadmap-sections …] [--items …] [--backlog …] … python3 $tk adopt scan --from … | apply --plan … # разовая адаптация, references/adopt.md ``` @@ -174,13 +246,14 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап **Мутации правят файл и индексы заодно** — руками строку индекса или мету не пиши, зови `add`/`edit`/`move`/`close`/`sprint`. Смена заголовка, «зачем», типа, -цели и **тегов** — это `edit`: он держит H1, мету и индекс в синхроне. +цели, рода работы и **тегов** — это `edit`: он держит H1, мету и индекс в синхроне. Снятие тега — `--rm-tag` (после ответа на вопрос снимается `question`), смена -цели — `--goal`, он заменяет прежний `goal:*`. +цели — `--goal`, рода — `--kind`; оба заменяют прежнее значение, а не добавляют +второе. **Переезд между индексами — следствие смены типа, а не отдельная команда.** -`edit --type goal --section <часть плана>` переносит строку из -`BACKLOG.md` в `PLAN.md` (и обратно `--type task --section <секция беклога>`); +`edit --type goal --section <часть роадмапа>` переносит строку из +`BACKLOG.md` в `ROADMAP.md` (и обратно `--type task --section <секция беклога>`); `move` двигает только внутри одного индекса и пишет причину. `--section` у `edit` работает **только** при таком переезде — иначе он отсылает к `move`, потому что смена секции без причины и есть тот дрейф, который потом никто не @@ -206,16 +279,22 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап и цель, у которой есть задачи, получает тег `decomposed`. Оба случая печатаются поимённо. -**Что механизировано, а что нет.** Критерии приёмки проверяются у задачи, взятой -в набор (`sprint take` и `check` по задачам спринта): число пунктов — жёстко -(меньше двух — отказ, больше пяти — замечание), наличие оракула — **эвристикой** -по слову «оракул» в пункте. Настоящий оракул от слова «оракул» машина не -отличает, поэтому эвристика даёт только замечание, и в докладе это называется -как есть: «проверено число пунктов, годность оракулов — глазами». +**Что механизировано, а что нет.** У задачи, взятой в набор (`sprint take` и +`check` по задачам спринта), проверяются три вещи, и у каждой своя глубина: + +- **критерии приёмки** — число пунктов жёстко (меньше двух отказ, больше пяти + замечание), наличие оракула **эвристикой** по слову «оракул» в пункте; +- **род работы** — жёстко: назван и из закрытого словаря; +- **раздел «Затрагивает»** — только **наличие непустого**. Полнота перечня машине + не видна: границу, которую забыли назвать, она от отсутствующей не отличает. + +Настоящий оракул от слова «оракул» машина тоже не отличает, поэтому эвристика +даёт только замечание, и в докладе это называется как есть: «проверено число +пунктов и наличие границ, годность оракулов и полнота границ — глазами». Формат файла, меты, слага, индексов и `REJECTED.md` — [references/task-format.md](references/task-format.md). Там же тест «готова к -взятию» и требования к критериям приёмки. +взятию», требования к критериям приёмки и раздел «Затрагивает». ## Сценарии @@ -239,12 +318,14 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап (`--type goal` в «темы»), либо это сигнал, что задача никому не служит и заводить её не надо. У идеи цели может не быть — она проставляется, когда идея становится задачей. -5. `add …`, затем допиши тело редактором: одна фраза, критерии приёмки с - оракулами, рамки. «Зачем» отвечает «зачем нужна эта задача» — состояние, - остаток, боль, — а не пересказывает первый абзац, и пишется **для человека**: - не «канонизация внутри транзакции», а «тело 40 МиБ держит блокировку 5 секунд, - соседние доставки уходят в отказ». -6. `check`. +5. **Род работы** — `--kind feature|fix|chore|research` (см. «Род работы»). Не + подходит ни один — задача не одна, разбирай. +6. `add …`, затем допиши тело редактором: одна фраза, **затрагиваемые границы**, + критерии приёмки с оракулами, рамки. «Зачем» отвечает «зачем нужна эта + задача» — состояние, остаток, боль, — а не пересказывает первый абзац, и + пишется **для человека**: не «канонизация внутри транзакции», а «тело 40 МиБ + держит блокировку 5 секунд, соседние доставки уходят в отказ». +7. `check`. ### Разобрать находки аудита или ревью @@ -258,7 +339,7 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап ### Прийти в репозиторий, где задачи уже как-то ведутся Разовая операция: вывести каталог задач из старой раскладки беклога, `TODO.md`, -заметок или списка шагов в плане — [references/adopt.md](references/adopt.md). +заметок или списка шагов роадмапа — [references/adopt.md](references/adopt.md). Сюда же относится переименование транслитных слагов в английские: оно делается **одним проходом вместе с починкой перекрёстных ссылок**, а не по одному слагу. @@ -291,7 +372,18 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап снимок берётся при постановке, а не при заведении; - **предписание процесса в теле** — «делать таким-то профилем ревью», «взять такой-то агент»: это второй дом для правила выбора и путь понизить требования - решением, принятым до проектирования. Снимается. + решением, принятым до проектирования. Снимается; +- **род, разошедшийся с задачей** — задача заводилась починкой, а после разбора + оказалось, что поведение никогда и не было заявлено: это `feature`, а не `fix`. + Правится `edit --kind …`; род, оставшийся от прошлой формулировки, врёт + ровно там, где по нему отбирают; +- **границы, названные вместо реализации** — «переписать хранилище на новый + драйвер» в разделе «Затрагивает» это не граница, а замысел. Границы — + `таблица points и её миграция`, `эндпоинт POST /ingest`, `формат отпечатка на + диске`. Переписывается перечнем; +- **англицизм и термин из ниоткуда** — правится по ходу той же операции, что + касается задачи (см. «Как написана задача»). Именно по ходу: беклог не + переписывают ради языка. ## Переносимость diff --git a/av-dev-pm/skills/tasks/references/adopt.md b/av-dev-pm/skills/tasks/references/adopt.md index ba4617f..20889ab 100644 --- a/av-dev-pm/skills/tasks/references/adopt.md +++ b/av-dev-pm/skills/tasks/references/adopt.md @@ -12,7 +12,7 @@ Вход какой угодно: старая раскладка `av-dev-backlog` (индекс `README.md`, кладбище `CLOSED.md`, приоритеты секциями, транслитные слаги, файлы рядом с индексом), `TODO.md`, россыпь заметок, раздел «планы» в `README.md`, список -шагов в плане проекта. +шагов роадмапа проекта. ## Три правила, из которых всё следует @@ -53,7 +53,7 @@ python3 $tk adopt apply --plan tasks-adopt-plan.json \ - **английские слаги.** Перевести `taj-brejk-pri-ravnoj-polnote` в `tie-break-equal-completeness` может только тот, кто понимает смысл. `scan` честно говорит: проверить надо **все** слаги, признаки транслита — эвристика; -- **цели.** Шаги плана — готовые цели из **«порядка»** (очередь и обоснование у +- **цели.** Шаги роадмапа — готовые цели из **«порядка»** (очередь и обоснование у них уже есть); тематические скопления задач — **«темы»** («прочность слияния», «журнал и пересборка»). Предлагаешь ты, назначает человек; - **что вообще не задача.** Обоснование порядка шагов, абзац прозой, заголовок @@ -61,7 +61,7 @@ python3 $tk adopt apply --plan tasks-adopt-plan.json \ ## Порядок -1. **Осмотрись.** Где лежат задачи, план, заметки. Каталог задач по канону — +1. **Осмотрись.** Где лежат задачи, роадмап, заметки. Каталог задач по канону — всегда `docs/tasks`. Секции беклога (`--sections`) — по умолчанию `ядро,инфра`; если у проекта деление другое по существу, оно называется здесь, а не подгоняется под умолчание, и становится **заголовками `##` @@ -69,7 +69,7 @@ python3 $tk adopt apply --plan tasks-adopt-plan.json \ 2. **`adopt scan`** по всем источникам разом. Один прогон, одна карта: два прохода дадут два несогласованных состояния. 3. **Заполни карту**: `slug` (английский), `section`, `goal` у каждой записи; - список `goals` — из шагов плана и из тем. Закрытый шаг плана целью не + список `goals` — из шагов роадмапа и из тем. Закрытый шаг целью не заводится. Пустой `goal` — законный исход только у идеи. 4. **Покажи человеку карту** через `AskUserQuestion`, ≤3 вопроса за итерацию, рекомендация первым вариантом. Показывается: сколько записей, предлагаемые diff --git a/av-dev-pm/skills/tasks/references/from-review.md b/av-dev-pm/skills/tasks/references/from-review.md index eb42ed5..27945a0 100644 --- a/av-dev-pm/skills/tasks/references/from-review.md +++ b/av-dev-pm/skills/tasks/references/from-review.md @@ -54,9 +54,14 @@ которого интейк из ревью и выделен. Дешёвая мелочь по явному согласию может заводиться и без поштучного вопроса — но карта пользователю предъявляется всё равно. -6. **Заводи утверждённое** через `tasks.py add`, с двумя добавками: +6. **Заводи утверждённое** через `tasks.py add`, с тремя добавками: - **тег партии** — `--tag review-ГГГГ-ММ-ДД` (или `audit-<тема>`), чтобы весь заход разбора поднимался одной командой `list --tag …`; + - **род работы** — `--kind`. У находок ревью он **не по умолчанию `fix`**: + починкой считается расхождение с заявленным поведением, а находка «этого + свойства никто не заказывал» — это `feature`, находка «не знаем, как + поведёт себя драйвер» — `research`. Род, розданный оптом, врёт ровно там, + где по нему потом отбирают; - **провенанс в теле** — кто нашёл, каким проходом, с каким свидетельством. Без него через месяц не отличить проверенную находку от догадки. 7. `tasks.py check`. diff --git a/av-dev-pm/skills/tasks/references/split.md b/av-dev-pm/skills/tasks/references/split.md index d6dd414..487e248 100644 --- a/av-dev-pm/skills/tasks/references/split.md +++ b/av-dev-pm/skills/tasks/references/split.md @@ -37,7 +37,7 @@ Зонтик, который перестал быть временным и описывает направление, а не работу, — это уже **цель**, а не эпик. Тип на месте не меняется (цель живёт в другом -индексе): заводится `[goal]` в `PLAN.md`, задачи получают `--goal <новый слаг>`, +индексе): заводится `[goal]` в `ROADMAP.md`, задачи получают `--goal <новый слаг>`, эпик закрывается с причиной-ссылкой. ## Когда декомпозиция случается посреди спринта diff --git a/av-dev-pm/skills/tasks/references/task-format.md b/av-dev-pm/skills/tasks/references/task-format.md index 3b9e8d7..81dc5fc 100644 --- a/av-dev-pm/skills/tasks/references/task-format.md +++ b/av-dev-pm/skills/tasks/references/task-format.md @@ -13,11 +13,16 @@ - **Секция:** ядро — вышла из спринта: остаток писал нерешённое в журнал - **Зачем:** порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт -- **Теги:** goal:merge-robustness, sprint:2026-08-03 +- **Теги:** goal:merge-robustness, kind:fix, sprint:2026-08-03 При столкновении точек выигрывает более полная, но при равной полноте побеждает последняя доставка — а она систематически беднее первой. +## Затрагивает + +Таблица `points` и её миграция; правило слияния в приёме доставки; формат +отпечатка состояния на диске. Публичного контракта не трогает. + ## Критерии приёмки - повторный прогон свёртки даёт тот же отпечаток состояния — оракул: команда сверки @@ -47,8 +52,11 @@ лежало только в индексе, штатная починка дрейфа теряла его молча и навсегда — а это единственное, по чему задачу выбирают, не открывая. -- **Тело** — одна фраза «что станет наблюдаемо иначе», критерии приёмки, рамки, - контекст, ссылки. Пишется на языке документации проекта. +- **Тело** — одна фраза «что станет наблюдаемо иначе», затрагиваемые границы, + критерии приёмки, рамки, контекст, ссылки. Пишется на языке документации + проекта: предметно, без англицизмов, у которых есть русское слово, и без + терминов, которых нет ни в паспорте, ни в архитектуре, ни в конвенциях + (правило и его причина — в SKILL.md, раздел «Как написана задача»). Мета **одной строкой через `·`** — прежняя форма. Она читается по-прежнему, `check` называет её дрейфом, `check --fix` переписывает списком; поле `Хук` @@ -59,6 +67,36 @@ Тело — не план реализации и не спецификация: принятое и реализованное переезжает в документацию проекта, а файл задачи удаляется. +### Затрагивает + +Перечень **границ**, которых изменение касается. Границей считается то, у чего +есть внешняя сторона и цена изменения: + +- эндпоинт, команда, форма ответа, код ответа; +- таблица, поле, миграция, формат на диске, формат сообщения в очереди; +- публичный тип или функция пакета, конфиг и его образцы; +- внешний сервис или библиотека, чьё поведение становится нужным. + +Ничего из этого не трогается — так и пишется: «границ не трогает, изменение +внутри одного узла». Это ответ, а не пустой раздел. + +**Границы, а не замысел.** «Переписать хранилище на новый драйвер» — замысел; +`таблица points и её миграция`, `эндпоинт POST /ingest` — границы. Разница +проверяется вопросом «это можно назвать до того, как решено *как* делать?»: если +нет, строка описывает реализацию, и её место в предложении об изменении. + +**Свойства репозитория сюда не пишутся** — по той же причине, что и в рамки: +имя таблицы стабильно, номер последней миграции протухает молча. Пишется +`таблица points и её миграция`, а не `миграция 0042`. + +**Что из этого механизировано.** `check` и `sprint take` смотрят только на +**наличие непустого раздела**. Полнота перечня машине не видна: границу, которую +забыли назвать, она от отсутствующей не отличает. Раздела нет — отказ во взятии: +оценивать нечем. + +**У идей раздела нет** — как и критериев: границы становятся известны, когда идея +превращается в задачу. + ### Критерии приёмки 2–5 проверяемых утверждений **списком** `- …`, **у каждого назван оракул**. Не @@ -139,7 +177,7 @@ `check` напоминает о нём у цели без задач замечанием — неразобранная цель законна и зелёного прогона не ломает; `check --fix` сам ставит его цели, у которой задачи есть, а цель с тегом и без задач — прямое приглашение закрыть. -- Цель живёт в `PLAN.md` и **никогда** — в `BACKLOG.md` или `SPRINT.md`. +- Цель живёт в `ROADMAP.md` и **никогда** — в `BACKLOG.md` или `SPRINT.md`. ## Слаг @@ -167,7 +205,7 @@ | Файл | Что отвечает | Секции | | --- | --- | --- | -| `PLAN.md` | какие есть цели, в какой очереди идут и почему | порядок (очередь значима) и темы (порядка нет) | +| `ROADMAP.md` | какие есть цели, в какой очереди идут и почему | порядок (очередь значима) и темы (порядка нет) | | `BACKLOG.md` | что **можно взять** — только задачи | секции проекта (по умолчанию ядро/инфра) | | `SPRINT.md` | какая цель и какой набор под неё | одна: «Набор» | | `REJECTED.md` | что ушло без реализации и почему | — | @@ -229,6 +267,12 @@ - `goal:<слаг>` — цель, которой служит задача. Обязателен: задача без цели не попадёт ни в один спринт. +- `kind:<род>` — род работы: `feature` | `fix` | `chore` | `research`. Словарь + **закрыт**, значение ровно одно. Обязателен у задачи (без него `sprint take` + откажет), у цели запрещён, у идеи и эпика необязателен. Ставится + `add --kind` / `edit --kind`; `--kind` заменяет прежнее значение, а не + добавляет второе. Смысл рода и почему он тегом, а не префиксом — в SKILL.md, + раздел «Род работы». - `question` — в файле есть неразобранный раздел «Вопросы». - `sprint:<слаг>` — задача заведена в этом спринте; по нему отбирается первая порция разбора («урожай спринта»). **Ставится сам**: слаг спринта заводит @@ -248,17 +292,22 @@ ## Тест «готова к взятию» -Задача готова, если из файла отвечаются три вопроса: +Задача готова, если из файла отвечаются четыре вопроса: 1. **Что станет наблюдаемо иначе**, когда она сделана — снаружи: пользователю, владельцу сервиса или разработчику. «Отрефакторить X» — не ответ; «перестанет - ломаться Y при Z» — ответ. -2. **По чему видно, что закончено** — критерии приёмки с оракулами. -3. **Какой цели она служит** — тег `goal:` и одна строка «почему именно этой». + ломаться Y при Z» — ответ. **У `kind:chore` адресат — разработчик, и это + законно**: «уедет последний вызов устаревшего API» — ответ, а не отговорка. + Род объявлен как раз затем, чтобы такие задачи не выдумывали себе + пользовательскую пользу. +2. **Каких границ это касается** — раздел «Затрагивает». Без него задачу нельзя + оценить: остаётся судить по длине текста. +3. **По чему видно, что закончено** — критерии приёмки с оракулами. +4. **Какой цели она служит** — тег `goal:` и одна строка «почему именно этой». -Не отвечается первый или второй вопрос → это **идея** (`[idea]`), её место в -штурме. Не отвечается третий → либо цель есть и не проставлена, либо задача не -служит ничему — тогда её не надо заводить. +Не отвечается первый, второй или третий вопрос → это **идея** (`[idea]`), её +место в штурме. Не отвечается четвёртый → либо цель есть и не проставлена, либо +задача не служит ничему — тогда её не надо заводить. Отвечается всё, но задача не делается одним заходом и не мерджится целиком → **эпик** (`[epic]`), сперва декомпозиция. Эпик временен и исчезает после diff --git a/av-dev-pm/skills/tasks/scripts/tasks.py b/av-dev-pm/skills/tasks/scripts/tasks.py index 8847efd..7243612 100755 --- a/av-dev-pm/skills/tasks/scripts/tasks.py +++ b/av-dev-pm/skills/tasks/scripts/tasks.py @@ -12,7 +12,7 @@ av-dev, и подгоняется под него проект. Имена вн docs/tasks/ items/ задачи и цели файлами, .md - PLAN.md оглавление целей: порядок (значим) и темы (без порядка) + ROADMAP.md оглавление целей: порядок (значим) и темы (без порядка) BACKLOG.md что можно взять — только задачи, целей здесь нет SPRINT.md текущий спринт: цель, набор, дата, слаг REJECTED.md ушедшее БЕЗ реализации, с причиной и датой @@ -32,14 +32,21 @@ av-dev, и подгоняется под него проект. Имена вн направление; эпик временен — это задача, которая не мерджится целиком, её разбирают, и он исчезает. +**Род работы — вторая ось, и она отвечает на другой вопрос.** Тип записи говорит, +что это за запись; род (`feature` | `fix` | `chore` | `research`, тегом +`kind:<род>`) — какого рода работа. Одним значением на два вопроса не ответить: +идея бывает *про* функцию, эпик *и есть* функция. Словарь закрыт — открытый +разъедется на синонимах, и отбор по роду перестанет отвечать. + Использование: - tasks.py init [--dir DIR] [--sections …] [--plan-sections …] [--items …] + tasks.py init [--dir DIR] [--sections …] [--roadmap-sections …] [--items …] tasks.py check [--dir DIR] [--fix] tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag a,b] - [--goal S] [--index backlog|sprint|plan|all] [--questions] + [--goal S] [--kind K] [--index backlog|sprint|roadmap|all] + [--questions] tasks.py add --slug S --title T [--type goal|idea|epic] [--section S] - [--goal G] [--why H] [--reason R] [--tag a,b] [--dir DIR] - tasks.py edit S [--title T] [--why H] [--type T] [--goal G] + [--goal G] [--kind K] [--why H] [--reason R] [--tag a,b] [--dir DIR] + tasks.py edit S [--title T] [--why H] [--type T] [--goal G] [--kind K] [--add-tag a,b] [--rm-tag c,d] [--section S] [--dir DIR] tasks.py move S --section S [--reason R] [--after S | --first] [--dir DIR] tasks.py close S (--reason R | --implemented) [--dir DIR] @@ -96,21 +103,22 @@ EXIT_INTERNAL = 4 DEFAULTS = { "items": "items", "backlog": "BACKLOG.md", - "plan": "PLAN.md", + "roadmap": "ROADMAP.md", "sprint": "SPRINT.md", "rejected": "REJECTED.md", "sprint_section": "Набор", "criteria_heading": "Критерии приёмки", + "surface_heading": "Затрагивает", "questions_heading": "Вопросы", "oracle_word": "оракул", } # Какие ключи конфига — имена файлов и каталогов (их существование сверяется # с диском первым делом, иначе кривой ключ выглядит как пропавший файл). -PATH_KEYS = ("items", "backlog", "plan", "sprint", "rejected") +PATH_KEYS = ("items", "backlog", "roadmap", "sprint", "rejected") DEFAULT_SECTIONS = "ядро,инфра" -DEFAULT_PLAN_SECTIONS = "порядок,темы" +DEFAULT_ROADMAP_SECTIONS = "порядок,темы" # Мета — список под заголовком, поле на строку. Старая форма (все поля одной # строкой через `·`) читается по-прежнему: у проектов на диске лежат файлы в @@ -177,6 +185,13 @@ TAKEABLE = (PLAIN_TYPE,) # что вообще можно взять QUESTION_TAG = "question" GOAL_TAG = "goal:" SPRINT_TAG = "sprint:" +# Род работы — вторая ось типа. Первая («тип записи»: goal/idea/epic/task) +# отвечает «что это за запись», вторая — «какого рода работа». Смешивать их в +# одном префиксе нельзя: идея *про* функцию, эпик *и есть* функция, и одно +# значение на два вопроса не отвечает. Дом рода — тег, потому что теги здесь и +# есть единственный механизм разметки, а `list --tag` уже умеет отбирать. +KIND_TAG = "kind:" +KINDS = ("feature", "fix", "chore", "research") DECOMPOSED_TAG = "decomposed" # цель разложена на задачи (см. «Статус цели») STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check CRITERIA_MIN, CRITERIA_MAX = 2, 5 # сколько утверждений в критериях приёмки @@ -231,6 +246,20 @@ def bad_tags(raw: str | None) -> str | None: return None +def bad_kind(kind: str | None) -> str | None: + """Род работы — закрытый словарь: открытый разъедется на синонимах. + + Пять человек заведут `bug`, `bugfix`, `fix`, `defect` и `починка`, и отбор + по роду перестанет отвечать на свой единственный вопрос. + """ + if kind is None: + return None + if kind.strip().lower() not in KINDS: + return (f"род работы «{kind}» не из словаря: {', '.join(KINDS)}." + f" Не подходит ни один — это сигнал, что задача не одна") + return None + + def split_tags(raw: str | None) -> list[str]: return [t.strip().lower() for t in (raw or "").split(",") if t.strip()] @@ -315,7 +344,7 @@ class Layout: @property def indexes(self) -> tuple[str, ...]: - return ("backlog", "sprint", "plan") + return ("backlog", "sprint", "roadmap") def load_config(root: Path) -> dict: @@ -356,6 +385,14 @@ def _read_json(path: Path) -> dict: def _validate_config(data: dict, path: Path) -> dict: unknown = set(data) - set(DEFAULTS) + # Ключ «plan» был домом оглавления целей до того, как файл стал ROADMAP.md. + # Без этой ветки проект со старым конфигом получал бы «неизвестный ключ» и + # искал опечатку там, где на самом деле переименование канона. + if "plan" in unknown: + raise Env(f"{path}: ключ «plan» переименован в «roadmap»," + f" а PLAN.md — в ROADMAP.md. Повысь проект скиллом" + f" av-dev-pm:canon (upgrade), а не правь ключ в одиночку:" + f" файл и ссылки на него переезжают вместе с ним") if unknown: raise Env(f"{path}: неизвестные ключи: {', '.join(sorted(unknown))}" f" (известны: {', '.join(sorted(DEFAULTS))})") @@ -391,7 +428,7 @@ def config_problems(lay: Layout) -> list[str]: out = [] if not lay.items.is_dir(): out.append(f"{where}: items = «{lay.cfg['items']}» → {lay.items} — каталога нет") - for kind in ("backlog", "plan", "sprint", "rejected"): + for kind in ("backlog", "roadmap", "sprint", "rejected"): p = lay.index(kind) if not p.is_file(): out.append(f"{where}: {kind} = «{lay.cfg[kind]}» → {p} — файла нет") @@ -525,9 +562,9 @@ 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 + rtype, bare = PLAIN_TYPE, title if (m := TYPE_PREFIX.match(title)): - kind, bare = m.group(1).strip().lower(), m.group(2).strip() + rtype, bare = m.group(1).strip().lower(), m.group(2).strip() # Мета — блок под заголовком (task-format.md). Порядок полей свободный: # секция распознаётся, где бы она ни стояла. section, reason, why, tags, legacy = "", "", "", [], False @@ -543,8 +580,10 @@ def parse_task(path: Path) -> dict: elif key in ("теги", "tags"): tags = [t.strip().lower() for t in value.split(",") if t.strip()] goal = next((t[len(GOAL_TAG):] for t in tags if t.startswith(GOAL_TAG)), "") - return {"title": title, "bare": bare, "type": kind, "section": section, - "reason": reason, "why": why, "tags": tags, "goal": goal, "path": path, + kind = next((t[len(KIND_TAG):] for t in tags if t.startswith(KIND_TAG)), "") + return {"title": title, "bare": bare, "type": rtype, "section": section, + "reason": reason, "why": why, "tags": tags, "goal": goal, "kind": kind, + "path": path, "legacy_meta": legacy, "text": text, "body": body_sections(text)} @@ -557,7 +596,7 @@ def tasks_of(lay: Layout) -> dict[str, dict]: def home_index(task: dict) -> str: """Индекс, которому задача принадлежит по типу. Спринт — исключение: туда задача попадает не по типу, а решением набора.""" - return "plan" if task["type"] == GOAL else "backlog" + return "roadmap" if task["type"] == GOAL else "backlog" def touched_map(lay: Layout) -> dict[str, str]: @@ -624,6 +663,25 @@ def criteria_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]: return errors, notes +def surface_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]: + """Отказы и замечания по разделу «Затрагивает». Общее для check и take. + + Раздел называет **границы**, которых изменение касается: эндпоинт, команду, + таблицу и миграцию, формат на диске, публичный тип пакета. Без него задача + оценивается по объёму текста, а не по объёму поверхности, — и оценка + систематически занижена ровно там, где текст короткий, а границ много. + + Механизируется только наличие непустого раздела. Полнота перечня машине не + видна: границу, которую забыли назвать, от отсутствующей она не отличает. + """ + name = task["path"].name + if not task["body"].get(lay.cfg["surface_heading"].lower()): + return ([f"{name}: нет раздела «{lay.cfg['surface_heading']}»" + f" — оценивать будет не по чему: границы (эндпоинт, таблица и" + f" миграция, формат на диске, публичный тип) не названы"], []) + return [], [] + + def questions_open(lay: Layout, task: dict) -> bool: """Открытый вопрос — это **непустой раздел**, а не тег. @@ -790,6 +848,21 @@ def check(lay: Layout, fix: bool = False) -> int: errors.append(f"{name}: тег {GOAL_TAG}{task['goal']} указывает на цель," f" которой нет в {lay.cfg['items']}/") + # 3а. Род работы. Замечание, а не отказ: беклог, заведённый до появления + # рода, законен, и переоформлять его «заодно» здесь не просят. + # Обязательным род становится там, где по нему принимают решение, — + # при взятии в спринт. + if task["kind"] and task["kind"] not in KINDS: + errors.append(f"{name}: род работы «{task['kind']}» не из словаря" + f" ({', '.join(KINDS)}) — словарь закрыт, иначе отбор" + f" по роду разъедется на синонимах") + elif task["type"] == GOAL and task["kind"]: + errors.append(f"{name}: у цели род работы «{task['kind']}» —" + f" цель это направление, а не работа; род несут её задачи") + elif task["type"] in TAKEABLE and not task["kind"]: + notes.append(f"{name}: без рода работы — `tasks.py edit {name[:-3]}" + f" --kind {'|'.join(KINDS)}`; в спринт без него не возьмут") + # 4. Спринт: задача с открытым вопросом в набор не берётся, и судит об # этом раздел, а не тег. if place == "sprint": @@ -806,9 +879,13 @@ def check(lay: Layout, fix: bool = False) -> int: if goal_of_sprint and task["goal"] and task["goal"] != goal_of_sprint: errors.append(f"{name}: цель задачи «{task['goal']}» не цель спринта" f" «{goal_of_sprint}» — набор служит одной цели") - e, n = criteria_verdict(lay, task) - errors += [f"{x} (в спринте)" for x in e] - notes += n + if not task["kind"]: + errors.append(f"{name}: в спринте без рода работы" + f" (`edit {name[:-3]} --kind …`)") + for verdict in (criteria_verdict, surface_verdict): + e, n = verdict(lay, task) + errors += [f"{x} (в спринте)" for x in e] + notes += n # 5. Тег «question» производен от раздела: раздел — факт, тег — метка. has_q_section = questions_open(lay, task) @@ -956,6 +1033,8 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int: continue if a.goal and t["goal"] != a.goal.lower(): continue + if a.kind and t["kind"] != a.kind.lower(): + continue if a.questions and not questions_open(lay, t): continue rows.append(t) @@ -970,11 +1049,12 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int: for t in rows: touched = f"{t.get('touched', ''):<11}" if a.stale else "" - kind = "" if t["type"] == PLAIN_TYPE else f"[{t['type']}] " + rtype = "" if t["type"] == PLAIN_TYPE else f"[{t['type']}] " + kind = "" if a.kind else f"{t['kind']:<9}" goal = f" →{t['goal']}" if t["goal"] and not a.goal else "" flag = " ?" if questions_open(lay, t) else " " - print(f"{touched}{t['place']:<8}{t['section']:<8}{flag} " - f"{t['path'].stem:<44} {kind}{t['bare']}{goal}") + print(f"{touched}{t['place']:<8}{t['section']:<8}{kind}{flag} " + f"{t['path'].stem:<44} {rtype}{t['bare']}{goal}") print(f"\nвсего: {len(rows)}") # Пустой ответ обязан объясняться: молчаливый ноль читается как «таких @@ -1038,7 +1118,7 @@ def insert_entry(lines: list[str], section: str, entry: str, after: str | None = None, first: bool = False) -> None: """Вставляет строку в секцию: по умолчанию в конец, --after <слаг> — следом за указанной строкой, --first — первой. Порядок нужен только упорядоченной - части плана; в беклоге он значения не имеет.""" + части роадмапа; в беклоге он значения не имеет.""" hi, _ = find_section(lines, section) if hi is None: raise Usage(f"секции «{section}» в индексе нет") @@ -1125,6 +1205,11 @@ def body_template(kind: str, lay: Layout) -> str: return ("\n") return ("\n\n" + f"## {lay.cfg['surface_heading']}\n\n" + "\n\n" f"## {lay.cfg['criteria_heading']}\n\n" f"\n\n" @@ -1135,17 +1220,18 @@ def body_template(kind: str, lay: Layout) -> str: def cmd_add(lay: Layout, a: argparse.Namespace) -> int: for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_why(a.why), - bad_tags(a.tag), bad_reason(a.reason), bad_slug(a.goal) if a.goal else None): + bad_tags(a.tag), bad_reason(a.reason), bad_kind(a.kind), + bad_slug(a.goal) if a.goal else None): if err: raise Usage(err) if not a.title.strip(): raise Usage("пустой заголовок") - kind = (a.type or "").strip().lower() + rtype = (a.type or "").strip().lower() path = lay.items / f"{a.slug}.md" if path.exists(): raise Usage(f"{path.name} уже существует — дедуп: допиши в него, а не заводи новый") - target = "plan" if kind == GOAL else "backlog" + target = "roadmap" if rtype == GOAL else "backlog" lines = read_lines(lay.index(target)) if not lines: raise Usage(f"нет индекса {lay.name(target)} — прогони tasks.py init") @@ -1162,23 +1248,28 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int: tags = [t for t in tags if not t.startswith(GOAL_TAG)] + [f"{GOAL_TAG}{a.goal}"] if not (lay.items / f"{a.goal}.md").exists(): print(f" внимание: цели {a.goal}.md нет — заведи её (--type goal) или поправь тег") - if kind != GOAL and not any(t.startswith(GOAL_TAG) for t in tags): + if a.kind: + tags = [t for t in tags if not t.startswith(KIND_TAG)] + [f"{KIND_TAG}{a.kind.lower()}"] + elif not rtype: + print(f" без рода работы — проставь `tasks.py edit {a.slug} --kind" + f" {'|'.join(KINDS)}`: в спринт без него не возьмут") + if rtype != GOAL and not any(t.startswith(GOAL_TAG) for t in tags): print(" без цели: задача вне цели не попадёт ни в один спринт —" f" проставь `tasks.py edit {a.slug} --goal <слаг>`") # Урожай спринта метится сам: тег, который никто не ставит, не отбирает # первую порцию переоценки, а именно на ней держится правило «сперва урожай». sslug = sprint_slug(lay) - if kind != GOAL and sslug and not any(t.startswith(SPRINT_TAG) for t in tags): + if rtype != GOAL and sslug and not any(t.startswith(SPRINT_TAG) for t in tags): tags.append(f"{SPRINT_TAG}{sslug}") if QUESTION_TAG in tags: print(f" тег «{QUESTION_TAG}»: не забудь раздел «{lay.cfg['questions_heading']}» в теле") - title_full = f"[{kind}] {a.title}" if kind else a.title + title_full = f"[{rtype}] {a.title}" if rtype else a.title meta = build_meta(section, a.reason or "", a.why or "", tags) insert_entry(lines, section, entry_line(lay, title_full, a.slug, a.why or "")) plan = Plan() - plan.file(path, f"# {title_full}\n\n{meta}\n\n{body_template(kind, lay)}") + plan.file(path, f"# {title_full}\n\n{meta}\n\n{body_template(rtype, lay)}") plan.index(lay, target, lines) plan.commit() @@ -1210,11 +1301,14 @@ def warn_rejected(lay: Layout, slug: str, title: str) -> None: def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_why(a.why), - bad_tags(a.add_tag), bad_tags(a.rm_tag), bad_slug(a.goal) if a.goal else None): + bad_tags(a.add_tag), bad_tags(a.rm_tag), bad_kind(a.kind), + bad_slug(a.goal) if a.goal else None): if err: raise Usage(err) - if all(v is None for v in (a.title, a.why, a.type, a.goal, a.add_tag, a.rm_tag, a.section)): - raise Usage("нечего менять: дай --title, --why, --type, --goal, --add-tag или --rm-tag") + if all(v is None for v in (a.title, a.why, a.type, a.goal, a.kind, + a.add_tag, a.rm_tag, a.section)): + raise Usage("нечего менять: дай --title, --why, --type, --goal, --kind," + " --add-tag или --rm-tag") path = lay.items / f"{a.slug}.md" if not path.exists(): raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/") @@ -1256,6 +1350,8 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: tags = [t for t in tags if not t.startswith(GOAL_TAG)] + [f"{GOAL_TAG}{a.goal}"] if not (lay.items / f"{a.goal}.md").exists(): print(f" внимание: цели {a.goal}.md нет — заведи её или поправь тег") + if a.kind is not None: + tags = [t for t in tags if not t.startswith(KIND_TAG)] + [f"{KIND_TAG}{a.kind.lower()}"] why = task["why"] if a.why is None else a.why section = task["section"] @@ -1303,7 +1399,8 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: plan.commit() changed = [n for n, v in (("заголовок", a.title), ("зачем", a.why), ("тип", a.type), - ("цель", a.goal), ("теги", a.add_tag or a.rm_tag)) + ("цель", a.goal), ("род работы", a.kind), + ("теги", a.add_tag or a.rm_tag)) if v is not None] print(f"{a.slug}: обновлено ({', '.join(changed)})") if len(places) > 1: @@ -1592,10 +1689,16 @@ def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int: raise Usage(f"{slug}: тег «{QUESTION_TAG}» стоит, а раздела" f" «{lay.cfg['questions_heading']}» нет — либо вопрос записан не туда," f" либо тег пора снять: `edit {slug} --rm-tag {QUESTION_TAG}`") - errs, notes = criteria_verdict(lay, t) - if errs: - raise Usage("; ".join(errs)) - warn += notes + if not t["kind"]: + raise Usage(f"{slug}: род работы не назван —" + f" `edit {slug} --kind {'|'.join(KINDS)}`." + f" По нему видно, что в наборе одни починки и ни одной" + f" функции, а это разговор про цель, а не про набор") + for verdict in (criteria_verdict, surface_verdict): + errs, notes = verdict(lay, t) + if errs: + raise Usage("; ".join(errs)) + warn += notes ei = find_entry_index(backlog_lines, slug) if ei is None: raise Usage(f"{slug}: строки в {lay.name('backlog')} нет" @@ -1699,7 +1802,7 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: меты файла; - «зачем», оставшееся только в индексе, — переносится в файл (миграция со старого формата: другого экземпляра нет, неоднозначности тоже); - - строка в чужом индексе (цель в беклоге, задача в плане) — переносится в + - строка в чужом индексе (цель в беклоге, задача в роадмапе) — переносится в домашний. Задача лежит ровно в одном индексе и не в том, выбирать не из чего: истина в типе, а тип в файле; - цель, у которой есть задачи, помечается `decomposed`. @@ -1853,7 +1956,7 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: # --- init --- -def init_files(lay: Layout, sections: list[str], plan_sections: list[str], +def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str], cfg: dict) -> dict[Path, str]: out: dict[Path, str] = {} if cfg: @@ -1875,20 +1978,20 @@ def init_files(lay: Layout, sections: list[str], plan_sections: list[str], "# Беклог\n\n" f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/.md`\n" "+ строка здесь. Целей тут нет — они в " - f"[{lay.name('plan')}]({lay.name('plan')}): беклог — то, что берут,\n" - "план — то, подо что берут. Порядка внутри секции нет: «что делать\n" + f"[{lay.name('roadmap')}]({lay.name('roadmap')}): беклог — то, что берут,\n" + "роадмап — то, подо что берут. Порядка внутри секции нет: «что делать\n" f"дальше» отвечает набор спринта. Ведётся скиллом `tasks`.\n\n" "Секции «блокеры» здесь нет и не заводится: блокер — это состояние\n" "(спринт не может продолжаться ни одной задачей), оно живёт до ответа\n" "человека, а его следы — вопросами в файлах задач.\n\n" + "".join(f"## {s}\n\n" for s in sections)) - out[lay.index("plan")] = ( - "# План\n\n" + out[lay.index("roadmap")] = ( + "# Роадмап\n\n" f"Оглавление целей. Цель — файл `[goal]` в `{lay.cfg['items']}/`; её задачи\n" "здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.\n" - f"В первой секции («{plan_sections[0]}») очередь значима и обосновывается\n" + f"В первой секции («{roadmap_sections[0]}») очередь значима и обосновывается\n" "прозой; в остальных порядка нет — это тематические цели.\n\n" - + "".join(f"## {s}\n\n" for s in plan_sections)) + + "".join(f"## {s}\n\n" for s in roadmap_sections)) out[lay.index("sprint")] = empty_sprint(lay) out[lay.index("rejected")] = ( "# Ушедшее без реализации\n\n" @@ -1911,14 +2014,14 @@ def uniq_sections(raw: str) -> list[str]: def cmd_init(root: Path, a: argparse.Namespace) -> int: if not dir_within_cwd(root): raise Usage(f"--dir вне рабочего каталога: {root}") - cfg = {k: v for k, v in (("items", a.items), ("backlog", a.backlog), ("plan", a.plan), + cfg = {k: v for k, v in (("items", a.items), ("backlog", a.backlog), ("roadmap", a.roadmap), ("sprint", a.sprint), ("rejected", a.rejected)) if v} lay = Layout(root, cfg) if lay.index("backlog").exists(): raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён") - sections, plan_sections = uniq_sections(a.sections), uniq_sections(a.plan_sections) - if not sections or not plan_sections: + sections, roadmap_sections = uniq_sections(a.sections), uniq_sections(a.roadmap_sections) + if not sections or not roadmap_sections: raise Usage("пустой список секций") blockers = [s for s in sections if s.lower() in BLOCKER_SECTIONS] if blockers: @@ -1930,11 +2033,12 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int: lay.items.mkdir(parents=True, exist_ok=True) plan = Plan() - for path, text in init_files(lay, sections, plan_sections, cfg).items(): + for path, text in init_files(lay, sections, roadmap_sections, cfg).items(): plan.file(path, text) plan.commit() print(f"каталог задач заведён: {root}") - print(f" секции беклога: {', '.join(sections)}; части плана: {', '.join(plan_sections)}") + print(f" секции беклога: {', '.join(sections)};" + f" части роадмапа: {', '.join(roadmap_sections)}") if cfg: pm = (root / PM_CONFIG_REL).resolve() print(f" имена частей записаны в {pm if pm.is_file() else root / CONFIG_NAME}") @@ -2091,7 +2195,7 @@ def cmd_adopt_scan(a: argparse.Namespace) -> int: rejected += sc.get("rejected", []) unclassified += sc.get("unclassified", []) goals += [{"slug": "", "title": g["title"], - "section": (a.plan_sections.split(",")[0].strip() or "порядок"), + "section": (a.roadmap_sections.split(",")[0].strip() or "порядок"), "from": g["from"], "step": g.get("step"), "done": g.get("done"), "body": f"Выведена из шага «{g['title']}» ({g['from']})." + ("\n\nШаг помечен закрытым — цель, скорее всего," @@ -2125,7 +2229,7 @@ def cmd_adopt_scan(a: argparse.Namespace) -> int: "sources": [str(s) for s in sources], "path_map": path_map, "sections_backlog": uniq_sections(a.sections), - "sections_plan": uniq_sections(a.plan_sections), + "sections_roadmap": uniq_sections(a.roadmap_sections), "section_map": {}, "goals": goals, "items": items, @@ -2242,9 +2346,9 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: raise Usage(f"target вне рабочего каталога: {root}") lay = Layout(root, {}) sections = pl.get("sections_backlog") or uniq_sections(DEFAULT_SECTIONS) - plan_sections = pl.get("sections_plan") or uniq_sections(DEFAULT_PLAN_SECTIONS) + roadmap_sections = pl.get("sections_roadmap") or uniq_sections(DEFAULT_ROADMAP_SECTIONS) known_sections = {s.lower() for s in sections} - known_plan = {s.lower() for s in plan_sections} + known_roadmap = {s.lower() for s in roadmap_sections} # --- проверки: все до первой записи --- problems: list[str] = [] @@ -2261,9 +2365,9 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: if g["slug"] in slugs: problems.append(f"слаг «{g['slug']}» встречается дважды") slugs.add(g["slug"]) - if g.get("section", "").lower() not in known_plan: + if g.get("section", "").lower() not in known_roadmap: problems.append(f"цель {g['slug']}: секция «{g.get('section', '')}»" - f" не из плана ({', '.join(plan_sections)})") + f" не из роадмапа ({', '.join(roadmap_sections)})") goal_slugs = {g["slug"] for g in pl.get("goals", []) if g.get("slug")} for it in pl.get("items", []): slug = it.get("slug") or it.get("old_slug") @@ -2290,10 +2394,10 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: # --- план записи --- wr = Plan() - for path, text in init_files(lay, sections, plan_sections, {}).items(): + for path, text in init_files(lay, sections, roadmap_sections, {}).items(): wr.file(path, text) - backlog_lines = init_files(lay, sections, plan_sections, {})[lay.index("backlog")].splitlines() - plan_lines = init_files(lay, sections, plan_sections, {})[lay.index("plan")].splitlines() + backlog_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("backlog")].splitlines() + roadmap_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("roadmap")].splitlines() renames: dict[str, str] = {} for g in pl.get("goals", []): @@ -2304,7 +2408,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: wr.file(lay.items / f"{g['slug']}.md", f"# {title}\n\n{meta}\n\n{body}\n\n## Завершение\n\n" f"\n") - insert_entry(plan_lines, g["section"].lower(), + insert_entry(roadmap_lines, g["section"].lower(), entry_line(lay, title, g["slug"], g.get("why", ""))) for it in pl.get("items", []): @@ -2339,7 +2443,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: entry_line(lay, title, slug, it.get("why", ""))) if pl.get("rejected"): - head = init_files(lay, sections, plan_sections, {})[lay.index("rejected")] + head = init_files(lay, sections, roadmap_sections, {})[lay.index("rejected")] body = [] for line in pl["rejected"]: line = re.sub(r"Был приоритет:", "Была секция:", line) @@ -2349,7 +2453,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: wr.file(lay.index("rejected"), head + "\n".join(body) + "\n") wr.file(lay.index("backlog"), "\n".join(backlog_lines)) - wr.file(lay.index("plan"), "\n".join(plan_lines)) + wr.file(lay.index("roadmap"), "\n".join(roadmap_lines)) if a.dry_run: print(f"пробный прогон: записалось бы файлов {len(wr.writes)}," @@ -2422,7 +2526,8 @@ def main() -> int: p.add_argument("--tag", help="тег или список через запятую (нужны ВСЕ):" " goal:<слаг>, question, sprint:<слаг>") p.add_argument("--goal", help="задачи одной цели (перечень выводится, а не хранится)") - p.add_argument("--index", choices=("backlog", "sprint", "plan", "all")) + p.add_argument("--kind", choices=KINDS, help="род работы → тег kind:<род>") + p.add_argument("--index", choices=("backlog", "sprint", "roadmap", "all")) p.add_argument("--questions", action="store_true", help="только с открытым вопросом") p = sub.add_parser("add", help="завести задачу, идею или цель") @@ -2432,27 +2537,29 @@ def main() -> int: p.add_argument("--type", choices=TYPES) p.add_argument("--section") p.add_argument("--goal", help="слаг цели → тег goal:<слаг>") + p.add_argument("--kind", choices=KINDS, help="род работы → тег kind:<род>") p.add_argument("--why") p.add_argument("--reason") p.add_argument("--tag") - p = sub.add_parser("edit", help="сменить заголовок/«зачем»/тип/цель/теги") + p = sub.add_parser("edit", help="сменить заголовок/«зачем»/тип/род/цель/теги") p.add_argument("slug") p.add_argument("--title") p.add_argument("--why") p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE)) p.add_argument("--goal", help="заменить тег goal:<слаг>") + p.add_argument("--kind", choices=KINDS, help="заменить тег kind:<род>") p.add_argument("--add-tag", dest="add_tag") p.add_argument("--rm-tag", dest="rm_tag") p.add_argument("--section", help="только вместе со сменой типа, меняющей индекс") p.add_argument("--dir") - p = sub.add_parser("move", help="перенести в другую секцию беклога или часть плана") + p = sub.add_parser("move", help="перенести в другую секцию беклога или часть роадмапа") p.add_argument("slug") p.add_argument("--section", required=True) p.add_argument("--reason") g = p.add_mutually_exclusive_group() - g.add_argument("--after", help="встать следом за этим слагом (упорядоченная часть плана)") + g.add_argument("--after", help="встать следом за этим слагом (упорядоченная часть роадмапа)") g.add_argument("--first", action="store_true") p.add_argument("--dir") @@ -2490,10 +2597,10 @@ def main() -> int: p = sub.add_parser("init", help="завести каталог задач в новом проекте") p.add_argument("--dir") p.add_argument("--sections", default=DEFAULT_SECTIONS) - p.add_argument("--plan-sections", dest="plan_sections", default=DEFAULT_PLAN_SECTIONS) + p.add_argument("--roadmap-sections", dest="roadmap_sections", default=DEFAULT_ROADMAP_SECTIONS) p.add_argument("--items") p.add_argument("--backlog") - p.add_argument("--plan") + p.add_argument("--roadmap") p.add_argument("--sprint") p.add_argument("--rejected") @@ -2504,7 +2611,7 @@ def main() -> int: s.add_argument("--target", default="docs/tasks") s.add_argument("--out", default="tasks-adopt-plan.json") s.add_argument("--sections", default=DEFAULT_SECTIONS) - s.add_argument("--plan-sections", dest="plan_sections", default=DEFAULT_PLAN_SECTIONS) + s.add_argument("--roadmap-sections", dest="roadmap_sections", default=DEFAULT_ROADMAP_SECTIONS) s = asub.add_parser("apply", help="записать каталог по подтверждённой карте") s.add_argument("--plan", required=True) s.add_argument("--refs", nargs="*", help="файлы и каталоги, где чинить ссылки на слаги")