канон версии 3: роадмап, род работы, границы задачи
Три изменения одной версией, потому что все три про одно — можно ли оценить задачу, не открывая код. 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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Канон документов проекта
|
# Канон документов проекта
|
||||||
|
|
||||||
**Версия 2.**
|
**Версия 3.**
|
||||||
|
|
||||||
Это **единственный дом определения канона**. Скиллы `init`, `canon` и `docs`
|
Это **единственный дом определения канона**. Скиллы `init`, `canon` и `docs`
|
||||||
читают его, а не пересказывают: три описания одной раскладки разъедутся, и
|
читают его, а не пересказывают: три описания одной раскладки разъедутся, и
|
||||||
@@ -39,7 +39,7 @@ docs/
|
|||||||
template.md
|
template.md
|
||||||
ADR-ГГГГ-ММ-ДД-slug.md
|
ADR-ГГГГ-ММ-ДД-slug.md
|
||||||
review.md настройка конвейера под проект + журнал дефектов
|
review.md настройка конвейера под проект + журнал дефектов
|
||||||
tasks/ скилл tasks: items/, PLAN.md, BACKLOG.md,
|
tasks/ скилл tasks: items/, ROADMAP.md, BACKLOG.md,
|
||||||
SPRINT.md, REJECTED.md
|
SPRINT.md, REJECTED.md
|
||||||
openspec/
|
openspec/
|
||||||
config.yaml только нужды генерации артефактов + ссылки
|
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`
|
### `CLAUDE.md`
|
||||||
|
|
||||||
Что это и стек; **инварианты с severity рядом с формулировкой** — по ним проходы
|
Что это и стек; **инварианты с severity рядом с формулировкой** — по ним проходы
|
||||||
@@ -234,7 +250,7 @@ kebab-case.
|
|||||||
| почему решено так | `adr/`, источник — архивный `design.md` |
|
| почему решено так | `adr/`, источник — архивный `design.md` |
|
||||||
| граница домена, «чем не является» | `passport.md` |
|
| граница домена, «чем не является» | `passport.md` |
|
||||||
| инвариант и его severity | `CLAUDE.md` |
|
| инвариант и его severity | `CLAUDE.md` |
|
||||||
| порядок работ и его обоснование | `docs/tasks/PLAN.md` |
|
| порядок работ и его обоснование | `docs/tasks/ROADMAP.md` |
|
||||||
| измеренное число | `research/` |
|
| измеренное число | `research/` |
|
||||||
| настройка с числовым значением | `database.md` |
|
| настройка с числовым значением | `database.md` |
|
||||||
| периметр и модель угроз | `security.md` |
|
| периметр и модель угроз | `security.md` |
|
||||||
@@ -267,8 +283,8 @@ kebab-case.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `docs/review-brief.md` | документы канона и есть бриф; остаток — в `review.md` |
|
| `docs/review-brief.md` | документы канона и есть бриф; остаток — в `review.md` |
|
||||||
| `docs/specs/` | `openspec/specs/` (поведение) и `architecture.md` (обзор) |
|
| `docs/specs/` | `openspec/specs/` (поведение) и `architecture.md` (обзор) |
|
||||||
| `docs/drafts/` | идея → задача `[idea]`; отказ → ADR; порядок → `PLAN.md`; размышление → `opsx:explore` |
|
| `docs/drafts/` | идея → задача `[idea]`; отказ → ADR; порядок → `ROADMAP.md`; размышление → `opsx:explore` |
|
||||||
| `docs/plan.md` | `docs/tasks/PLAN.md` |
|
| `docs/plan.md` | `docs/tasks/ROADMAP.md` |
|
||||||
| `BRIEF.md` | `passport.md` |
|
| `BRIEF.md` | `passport.md` |
|
||||||
| `docs/backlog/` | `docs/tasks/` |
|
| `docs/backlog/` | `docs/tasks/` |
|
||||||
| `docs/review-journal.md`, `docs/review/journal.md` | `docs/review.md` |
|
| `docs/review-journal.md`, `docs/review/journal.md` | `docs/review.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
|
## Версия 2 — 2026-08-03
|
||||||
|
|
||||||
Шапка записи ADR — мета-блоком общей формы, и у статуса появился объявленный
|
Шапка записи ADR — мета-блоком общей формы, и у статуса появился объявленный
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
# Паспорт проекта
|
# Паспорт проекта
|
||||||
|
|
||||||
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
|
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
|
||||||
устроено», [tasks/PLAN.md](tasks/PLAN.md) — «в каком порядке», паспорт —
|
устроено», [tasks/ROADMAP.md](tasks/ROADMAP.md) — «в каком порядке», паспорт —
|
||||||
«зачем и для кого».
|
«зачем и для кого».
|
||||||
|
|
||||||
## Цель
|
## Цель
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import NoReturn
|
from typing import NoReturn
|
||||||
|
|
||||||
CANON_VERSION = 2
|
CANON_VERSION = 3
|
||||||
|
|
||||||
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
|
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
|
||||||
|
|
||||||
@@ -65,12 +65,12 @@ ALLOWED_DIRS = {"conventions", "research", "adr", "tasks"}
|
|||||||
RETIRED = {
|
RETIRED = {
|
||||||
"review-brief.md": "документы канона и есть бриф; остаток — в review.md",
|
"review-brief.md": "документы канона и есть бриф; остаток — в review.md",
|
||||||
"review-journal.md": "→ docs/review.md",
|
"review-journal.md": "→ docs/review.md",
|
||||||
"plan.md": "→ docs/tasks/PLAN.md",
|
"plan.md": "→ docs/tasks/ROADMAP.md",
|
||||||
"conventions.md": "→ docs/conventions/",
|
"conventions.md": "→ docs/conventions/",
|
||||||
"local-research.md": "→ docs/research/",
|
"local-research.md": "→ docs/research/",
|
||||||
"research.md": "→ docs/research/",
|
"research.md": "→ docs/research/",
|
||||||
"specs": "поведение → openspec/specs/, обзор → docs/architecture.md",
|
"specs": "поведение → openspec/specs/, обзор → docs/architecture.md",
|
||||||
"drafts": "идея → задача [idea], отказ → ADR, порядок → PLAN.md",
|
"drafts": "идея → задача [idea], отказ → ADR, порядок → ROADMAP.md",
|
||||||
"backlog": "→ docs/tasks/",
|
"backlog": "→ docs/tasks/",
|
||||||
"review": "→ docs/review.md",
|
"review": "→ docs/review.md",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ description: "Завести новый проект — сессия вопро
|
|||||||
| `passport.md` | `architecture.md` |
|
| `passport.md` | `architecture.md` |
|
||||||
| `CLAUDE.md` | `database.md` |
|
| `CLAUDE.md` | `database.md` |
|
||||||
| `security.md` | `conventions/` |
|
| `security.md` | `conventions/` |
|
||||||
| `docs/tasks/PLAN.md` — первые цели | `research/`, `adr/` |
|
| `docs/tasks/ROADMAP.md` — первые цели | `research/`, `adr/` |
|
||||||
| `docs/.pm.json` | `review.md` — журнал пуст, настройка появится с первым ревью |
|
| `docs/.pm.json` | `review.md` — журнал пуст, настройка появится с первым ревью |
|
||||||
|
|
||||||
Честная строка информативна, а не «TBD»: «архитектуры пока нет: кода нет,
|
Честная строка информативна, а не «TBD»: «архитектуры пока нет: кода нет,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ description: "Ритуал между спринтами и ведение са
|
|||||||
## Единицы
|
## Единицы
|
||||||
|
|
||||||
- **Цель** — то, ради чего набирается спринт. Файл `[goal]`, перечисленный в
|
- **Цель** — то, ради чего набирается спринт. Файл `[goal]`, перечисленный в
|
||||||
`PLAN.md`. Цель постоянна: живёт, пока живёт направление.
|
`ROADMAP.md`. Цель постоянна: живёт, пока живёт направление.
|
||||||
- **Задача** — то, что мерджится целиком и даёт видимую пользу.
|
- **Задача** — то, что мерджится целиком и даёт видимую пользу.
|
||||||
- **Вопрос** — решение человека. Не останавливает начатую работу, но **блокирует
|
- **Вопрос** — решение человека. Не останавливает начатую работу, но **блокирует
|
||||||
взятие** задачи в спринт. Живёт внутри файла задачи разделом «Вопросы» и тегом
|
взятие** задачи в спринт. Живёт внутри файла задачи разделом «Вопросы» и тегом
|
||||||
|
|||||||
@@ -105,8 +105,11 @@
|
|||||||
4. **Пере-кластеризуй по общей причине.** Несколько задач, оказавшихся симптомами
|
4. **Пере-кластеризуй по общей причине.** Несколько задач, оказавшихся симптомами
|
||||||
одного дефекта, сливаются в одну — это находка, которую интейк дать не мог.
|
одного дефекта, сливаются в одну — это находка, которую интейк дать не мог.
|
||||||
5. **Гигиена полей** — протухшее «зачем», вопрос в прозе, снятый ответ, свойство
|
5. **Гигиена полей** — протухшее «зачем», вопрос в прозе, снятый ответ, свойство
|
||||||
репозитория в рамках, предписание процесса в теле. Список и правила — в
|
репозитория в рамках, предписание процесса в теле, род работы, разошедшийся с
|
||||||
скилле `tasks`.
|
задачей, границы вместо реализации в разделе «Затрагивает». Список и правила —
|
||||||
|
в скилле `tasks`. **Переоценка — то самое место, где беклог добирает род
|
||||||
|
работы и границы:** требовать их на входе значило бы выгонять в заметки то,
|
||||||
|
что должно лежать задачей, а к взятию в спринт они уже обязательны.
|
||||||
|
|
||||||
Затем — то, что решает пользователь:
|
Затем — то, что решает пользователь:
|
||||||
|
|
||||||
@@ -170,21 +173,24 @@
|
|||||||
|
|
||||||
## Шаг 4. Выбор цели и набор спринта
|
## Шаг 4. Выбор цели и набор спринта
|
||||||
|
|
||||||
1. **Покажи состояние целей**: «порядок» `PLAN.md` с обоснованием очереди, темы, и
|
1. **Покажи состояние целей**: «порядок» `ROADMAP.md` с обоснованием очереди, темы, и
|
||||||
по каждой цели-кандидату — сколько под ней задач без открытых вопросов
|
по каждой цели-кандидату — сколько под ней задач без открытых вопросов
|
||||||
(`list --goal <слаг>`). Цель без готовых задач набором не станет: её сперва
|
(`list --goal <слаг>`). Цель без готовых задач набором не станет: её сперва
|
||||||
надо декомпозировать.
|
надо декомпозировать.
|
||||||
2. **Цель называет человек.** Это продуктовое решение, а не механика: агент
|
2. **Цель называет человек.** Это продуктовое решение, а не механика: агент
|
||||||
предлагает и объясняет, но не выбирает.
|
предлагает и объясняет, но не выбирает.
|
||||||
3. **Набор собирает агент** — `sprint start --goal <слаг>`, затем `sprint take
|
3. **Набор собирает агент** — `sprint start --goal <слаг>`, затем `sprint take
|
||||||
…`. Скрипт не даст взять чужую цель, идею, эпик, задачу с открытым вопросом
|
…`. Скрипт не даст взять чужую цель, идею, эпик, задачу с открытым вопросом,
|
||||||
или без критериев приёмки.
|
без критериев приёмки, без рода работы или без раздела «Затрагивает».
|
||||||
4. **Набор показывается человеку до старта работ.** Показ — это и есть момент
|
4. **Набор показывается человеку до старта работ.** Показ — это и есть момент
|
||||||
заморозки: после него набор не двигается.
|
заморозки: после него набор не двигается. **В показе называется состав по
|
||||||
5. Задача, которой для взятия не хватает только критериев приёмки, дописывается
|
роду работы** — три `fix` и ни одной `feature` под целью развития это
|
||||||
здесь же — 2–5 утверждений, у каждого назван оракул (меньше двух `sprint
|
разговор про цель, а не про набор, и увидеть его надо до заморозки, а не в
|
||||||
take` не примет). Но если для критериев нужен ответ человека, это вопрос, и
|
докладе по итогам.
|
||||||
задача в набор не идёт.
|
5. Задача, которой для взятия не хватает только критериев приёмки, границ или
|
||||||
|
рода, дописывается здесь же — 2–5 утверждений с оракулами, перечень
|
||||||
|
затрагиваемых границ, `--kind`. Но если для этого нужен ответ человека, это
|
||||||
|
вопрос, и задача в набор не идёт.
|
||||||
|
|
||||||
**Размер — ориентир, а не закон:** 5–8 задач. Можно взять больше, можно меньше —
|
**Размер — ориентир, а не закон:** 5–8 задач. Можно взять больше, можно меньше —
|
||||||
набор под цель важнее круглого числа; одна крупная задача спринтом тоже бывает.
|
набор под цель важнее круглого числа; одна крупная задача спринтом тоже бывает.
|
||||||
@@ -196,7 +202,7 @@
|
|||||||
- Разбор процесса: что записано и куда.
|
- Разбор процесса: что записано и куда.
|
||||||
- Изменения списком: удалено как реализованное (со ссылками), ушло без
|
- Изменения списком: удалено как реализованное (со ссылками), ушло без
|
||||||
реализации (с причинами), понижено до идей, слито, сменило цель.
|
реализации (с причинами), понижено до идей, слито, сменило цель.
|
||||||
- Новый спринт: цель, набор со слагами, дата.
|
- Новый спринт: цель, набор со слагами, дата, состав по роду работы.
|
||||||
- **Границы покрытия**: сколько задач не трогали и какие именно секции, теги или
|
- **Границы покрытия**: сколько задач не трогали и какие именно секции, теги или
|
||||||
цели остались — иначе доклад читается как «беклог разобран».
|
цели остались — иначе доклад читается как «беклог разобран».
|
||||||
- `tasks.py check` после правок — результат строкой.
|
- `tasks.py check` после правок — результат строкой.
|
||||||
|
|||||||
+119
-27
@@ -48,13 +48,13 @@ description: Ведение задач и целей как каталога mar
|
|||||||
```
|
```
|
||||||
docs/tasks/
|
docs/tasks/
|
||||||
items/ задачи и цели файлами, <slug>.md, слаги английские
|
items/ задачи и цели файлами, <slug>.md, слаги английские
|
||||||
PLAN.md оглавление целей: порядок (значим) и темы (без порядка)
|
ROADMAP.md оглавление целей: порядок (значим) и темы (без порядка)
|
||||||
BACKLOG.md что можно взять — только задачи, целей здесь нет
|
BACKLOG.md что можно взять — только задачи, целей здесь нет
|
||||||
SPRINT.md текущий спринт: цель, набор, дата
|
SPRINT.md текущий спринт: цель, набор, дата
|
||||||
REJECTED.md ушедшее БЕЗ реализации, с причиной и датой
|
REJECTED.md ушедшее БЕЗ реализации, с причиной и датой
|
||||||
```
|
```
|
||||||
|
|
||||||
Правило, снимающее путаницу: **`BACKLOG.md` — то, что берут; `PLAN.md` — то,
|
Правило, снимающее путаницу: **`BACKLOG.md` — то, что берут; `ROADMAP.md` — то,
|
||||||
подо что берут.** Цель в спринт взять нельзя, поэтому в списке берущихся ей не
|
подо что берут.** Цель в спринт взять нельзя, поэтому в списке берущихся ей не
|
||||||
место.
|
место.
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ docs/tasks/
|
|||||||
```mermaid
|
```mermaid
|
||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
state "BACKLOG.md — что берут" as B
|
state "BACKLOG.md — что берут" as B
|
||||||
state "PLAN.md — подо что берут" as P
|
state "ROADMAP.md — подо что берут" as P
|
||||||
state "SPRINT.md — набор спринта" as S
|
state "SPRINT.md — набор спринта" as S
|
||||||
state "REJECTED.md — ушла без реализации" as R
|
state "REJECTED.md — ушла без реализации" as R
|
||||||
state "записи нет — реализована" as D
|
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`)
|
## Инструмент (`tasks.py`)
|
||||||
|
|
||||||
Пусть `tk="$CLAUDE_PLUGIN_ROOT/skills/tasks/scripts/tasks.py"`, а `D` —
|
Пусть `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 # согласованность индексов + здоровье
|
||||||
python3 $tk check --dir D --fix # + починить дрейф (секция, заголовок, дубли, «зачем», форма меты)
|
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 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] [--why «зачем»] [--tag a,b]
|
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] [--add-tag a,b] [--rm-tag c]
|
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 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 --reason R # в REJECTED.md + удалить (ушла без реализации)
|
||||||
python3 $tk close S --dir D --implemented # просто удалить (реализована и закоммичена)
|
python3 $tk close S --dir D --implemented # просто удалить (реализована и закоммичена)
|
||||||
python3 $tk reopen S --dir D --reason R # вернуть закрытую: приёмка не сошлась
|
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 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
|
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`. Смена заголовка, «зачем», типа,
|
не пиши, зови `add`/`edit`/`move`/`close`/`sprint`. Смена заголовка, «зачем», типа,
|
||||||
цели и **тегов** — это `edit`: он держит H1, мету и индекс в синхроне.
|
цели, рода работы и **тегов** — это `edit`: он держит H1, мету и индекс в синхроне.
|
||||||
Снятие тега — `--rm-tag` (после ответа на вопрос снимается `question`), смена
|
Снятие тега — `--rm-tag` (после ответа на вопрос снимается `question`), смена
|
||||||
цели — `--goal`, он заменяет прежний `goal:*`.
|
цели — `--goal`, рода — `--kind`; оба заменяют прежнее значение, а не добавляют
|
||||||
|
второе.
|
||||||
|
|
||||||
**Переезд между индексами — следствие смены типа, а не отдельная команда.**
|
**Переезд между индексами — следствие смены типа, а не отдельная команда.**
|
||||||
`edit <slug> --type goal --section <часть плана>` переносит строку из
|
`edit <slug> --type goal --section <часть роадмапа>` переносит строку из
|
||||||
`BACKLOG.md` в `PLAN.md` (и обратно `--type task --section <секция беклога>`);
|
`BACKLOG.md` в `ROADMAP.md` (и обратно `--type task --section <секция беклога>`);
|
||||||
`move` двигает только внутри одного индекса и пишет причину. `--section` у
|
`move` двигает только внутри одного индекса и пишет причину. `--section` у
|
||||||
`edit` работает **только** при таком переезде — иначе он отсылает к `move`,
|
`edit` работает **только** при таком переезде — иначе он отсылает к `move`,
|
||||||
потому что смена секции без причины и есть тот дрейф, который потом никто не
|
потому что смена секции без причины и есть тот дрейф, который потом никто не
|
||||||
@@ -206,16 +279,22 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап
|
|||||||
и цель, у которой есть задачи, получает тег `decomposed`. Оба случая печатаются
|
и цель, у которой есть задачи, получает тег `decomposed`. Оба случая печатаются
|
||||||
поимённо.
|
поимённо.
|
||||||
|
|
||||||
**Что механизировано, а что нет.** Критерии приёмки проверяются у задачи, взятой
|
**Что механизировано, а что нет.** У задачи, взятой в набор (`sprint take` и
|
||||||
в набор (`sprint take` и `check` по задачам спринта): число пунктов — жёстко
|
`check` по задачам спринта), проверяются три вещи, и у каждой своя глубина:
|
||||||
(меньше двух — отказ, больше пяти — замечание), наличие оракула — **эвристикой**
|
|
||||||
по слову «оракул» в пункте. Настоящий оракул от слова «оракул» машина не
|
- **критерии приёмки** — число пунктов жёстко (меньше двух отказ, больше пяти
|
||||||
отличает, поэтому эвристика даёт только замечание, и в докладе это называется
|
замечание), наличие оракула **эвристикой** по слову «оракул» в пункте;
|
||||||
как есть: «проверено число пунктов, годность оракулов — глазами».
|
- **род работы** — жёстко: назван и из закрытого словаря;
|
||||||
|
- **раздел «Затрагивает»** — только **наличие непустого**. Полнота перечня машине
|
||||||
|
не видна: границу, которую забыли назвать, она от отсутствующей не отличает.
|
||||||
|
|
||||||
|
Настоящий оракул от слова «оракул» машина тоже не отличает, поэтому эвристика
|
||||||
|
даёт только замечание, и в докладе это называется как есть: «проверено число
|
||||||
|
пунктов и наличие границ, годность оракулов и полнота границ — глазами».
|
||||||
|
|
||||||
Формат файла, меты, слага, индексов и `REJECTED.md` —
|
Формат файла, меты, слага, индексов и `REJECTED.md` —
|
||||||
[references/task-format.md](references/task-format.md). Там же тест «готова к
|
[references/task-format.md](references/task-format.md). Там же тест «готова к
|
||||||
взятию» и требования к критериям приёмки.
|
взятию», требования к критериям приёмки и раздел «Затрагивает».
|
||||||
|
|
||||||
## Сценарии
|
## Сценарии
|
||||||
|
|
||||||
@@ -239,12 +318,14 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап
|
|||||||
(`--type goal` в «темы»), либо это сигнал, что задача никому не служит и
|
(`--type goal` в «темы»), либо это сигнал, что задача никому не служит и
|
||||||
заводить её не надо. У идеи цели может не быть — она проставляется, когда
|
заводить её не надо. У идеи цели может не быть — она проставляется, когда
|
||||||
идея становится задачей.
|
идея становится задачей.
|
||||||
5. `add …`, затем допиши тело редактором: одна фраза, критерии приёмки с
|
5. **Род работы** — `--kind feature|fix|chore|research` (см. «Род работы»). Не
|
||||||
оракулами, рамки. «Зачем» отвечает «зачем нужна эта задача» — состояние,
|
подходит ни один — задача не одна, разбирай.
|
||||||
остаток, боль, — а не пересказывает первый абзац, и пишется **для человека**:
|
6. `add …`, затем допиши тело редактором: одна фраза, **затрагиваемые границы**,
|
||||||
не «канонизация внутри транзакции», а «тело 40 МиБ держит блокировку 5 секунд,
|
критерии приёмки с оракулами, рамки. «Зачем» отвечает «зачем нужна эта
|
||||||
соседние доставки уходят в отказ».
|
задача» — состояние, остаток, боль, — а не пересказывает первый абзац, и
|
||||||
6. `check`.
|
пишется **для человека**: не «канонизация внутри транзакции», а «тело 40 МиБ
|
||||||
|
держит блокировку 5 секунд, соседние доставки уходят в отказ».
|
||||||
|
7. `check`.
|
||||||
|
|
||||||
### Разобрать находки аудита или ревью
|
### Разобрать находки аудита или ревью
|
||||||
|
|
||||||
@@ -258,7 +339,7 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап
|
|||||||
### Прийти в репозиторий, где задачи уже как-то ведутся
|
### Прийти в репозиторий, где задачи уже как-то ведутся
|
||||||
|
|
||||||
Разовая операция: вывести каталог задач из старой раскладки беклога, `TODO.md`,
|
Разовая операция: вывести каталог задач из старой раскладки беклога, `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 <slug> --kind …`; род, оставшийся от прошлой формулировки, врёт
|
||||||
|
ровно там, где по нему отбирают;
|
||||||
|
- **границы, названные вместо реализации** — «переписать хранилище на новый
|
||||||
|
драйвер» в разделе «Затрагивает» это не граница, а замысел. Границы —
|
||||||
|
`таблица points и её миграция`, `эндпоинт POST /ingest`, `формат отпечатка на
|
||||||
|
диске`. Переписывается перечнем;
|
||||||
|
- **англицизм и термин из ниоткуда** — правится по ходу той же операции, что
|
||||||
|
касается задачи (см. «Как написана задача»). Именно по ходу: беклог не
|
||||||
|
переписывают ради языка.
|
||||||
|
|
||||||
## Переносимость
|
## Переносимость
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
Вход какой угодно: старая раскладка `av-dev-backlog` (индекс `README.md`,
|
Вход какой угодно: старая раскладка `av-dev-backlog` (индекс `README.md`,
|
||||||
кладбище `CLOSED.md`, приоритеты секциями, транслитные слаги, файлы рядом с
|
кладбище `CLOSED.md`, приоритеты секциями, транслитные слаги, файлы рядом с
|
||||||
индексом), `TODO.md`, россыпь заметок, раздел «планы» в `README.md`, список
|
индексом), `TODO.md`, россыпь заметок, раздел «планы» в `README.md`, список
|
||||||
шагов в плане проекта.
|
шагов роадмапа проекта.
|
||||||
|
|
||||||
## Три правила, из которых всё следует
|
## Три правила, из которых всё следует
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ python3 $tk adopt apply --plan tasks-adopt-plan.json \
|
|||||||
- **английские слаги.** Перевести `taj-brejk-pri-ravnoj-polnote` в
|
- **английские слаги.** Перевести `taj-brejk-pri-ravnoj-polnote` в
|
||||||
`tie-break-equal-completeness` может только тот, кто понимает смысл. `scan`
|
`tie-break-equal-completeness` может только тот, кто понимает смысл. `scan`
|
||||||
честно говорит: проверить надо **все** слаги, признаки транслита — эвристика;
|
честно говорит: проверить надо **все** слаги, признаки транслита — эвристика;
|
||||||
- **цели.** Шаги плана — готовые цели из **«порядка»** (очередь и обоснование у
|
- **цели.** Шаги роадмапа — готовые цели из **«порядка»** (очередь и обоснование у
|
||||||
них уже есть); тематические скопления задач — **«темы»** («прочность слияния»,
|
них уже есть); тематические скопления задач — **«темы»** («прочность слияния»,
|
||||||
«журнал и пересборка»). Предлагаешь ты, назначает человек;
|
«журнал и пересборка»). Предлагаешь ты, назначает человек;
|
||||||
- **что вообще не задача.** Обоснование порядка шагов, абзац прозой, заголовок
|
- **что вообще не задача.** Обоснование порядка шагов, абзац прозой, заголовок
|
||||||
@@ -61,7 +61,7 @@ python3 $tk adopt apply --plan tasks-adopt-plan.json \
|
|||||||
|
|
||||||
## Порядок
|
## Порядок
|
||||||
|
|
||||||
1. **Осмотрись.** Где лежат задачи, план, заметки. Каталог задач по канону —
|
1. **Осмотрись.** Где лежат задачи, роадмап, заметки. Каталог задач по канону —
|
||||||
всегда `docs/tasks`. Секции беклога (`--sections`) — по умолчанию
|
всегда `docs/tasks`. Секции беклога (`--sections`) — по умолчанию
|
||||||
`ядро,инфра`; если у проекта деление другое по существу, оно называется
|
`ядро,инфра`; если у проекта деление другое по существу, оно называется
|
||||||
здесь, а не подгоняется под умолчание, и становится **заголовками `##`
|
здесь, а не подгоняется под умолчание, и становится **заголовками `##`
|
||||||
@@ -69,7 +69,7 @@ python3 $tk adopt apply --plan tasks-adopt-plan.json \
|
|||||||
2. **`adopt scan`** по всем источникам разом. Один прогон, одна карта: два
|
2. **`adopt scan`** по всем источникам разом. Один прогон, одна карта: два
|
||||||
прохода дадут два несогласованных состояния.
|
прохода дадут два несогласованных состояния.
|
||||||
3. **Заполни карту**: `slug` (английский), `section`, `goal` у каждой записи;
|
3. **Заполни карту**: `slug` (английский), `section`, `goal` у каждой записи;
|
||||||
список `goals` — из шагов плана и из тем. Закрытый шаг плана целью не
|
список `goals` — из шагов роадмапа и из тем. Закрытый шаг целью не
|
||||||
заводится. Пустой `goal` — законный исход только у идеи.
|
заводится. Пустой `goal` — законный исход только у идеи.
|
||||||
4. **Покажи человеку карту** через `AskUserQuestion`, ≤3 вопроса за итерацию,
|
4. **Покажи человеку карту** через `AskUserQuestion`, ≤3 вопроса за итерацию,
|
||||||
рекомендация первым вариантом. Показывается: сколько записей, предлагаемые
|
рекомендация первым вариантом. Показывается: сколько записей, предлагаемые
|
||||||
|
|||||||
@@ -54,9 +54,14 @@
|
|||||||
которого интейк из ревью и выделен. Дешёвая мелочь по явному согласию может
|
которого интейк из ревью и выделен. Дешёвая мелочь по явному согласию может
|
||||||
заводиться и без поштучного вопроса — но карта пользователю предъявляется
|
заводиться и без поштучного вопроса — но карта пользователю предъявляется
|
||||||
всё равно.
|
всё равно.
|
||||||
6. **Заводи утверждённое** через `tasks.py add`, с двумя добавками:
|
6. **Заводи утверждённое** через `tasks.py add`, с тремя добавками:
|
||||||
- **тег партии** — `--tag review-ГГГГ-ММ-ДД` (или `audit-<тема>`), чтобы весь
|
- **тег партии** — `--tag review-ГГГГ-ММ-ДД` (или `audit-<тема>`), чтобы весь
|
||||||
заход разбора поднимался одной командой `list --tag …`;
|
заход разбора поднимался одной командой `list --tag …`;
|
||||||
|
- **род работы** — `--kind`. У находок ревью он **не по умолчанию `fix`**:
|
||||||
|
починкой считается расхождение с заявленным поведением, а находка «этого
|
||||||
|
свойства никто не заказывал» — это `feature`, находка «не знаем, как
|
||||||
|
поведёт себя драйвер» — `research`. Род, розданный оптом, врёт ровно там,
|
||||||
|
где по нему потом отбирают;
|
||||||
- **провенанс в теле** — кто нашёл, каким проходом, с каким свидетельством.
|
- **провенанс в теле** — кто нашёл, каким проходом, с каким свидетельством.
|
||||||
Без него через месяц не отличить проверенную находку от догадки.
|
Без него через месяц не отличить проверенную находку от догадки.
|
||||||
7. `tasks.py check`.
|
7. `tasks.py check`.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
Зонтик, который перестал быть временным и описывает направление, а не работу, —
|
Зонтик, который перестал быть временным и описывает направление, а не работу, —
|
||||||
это уже **цель**, а не эпик. Тип на месте не меняется (цель живёт в другом
|
это уже **цель**, а не эпик. Тип на месте не меняется (цель живёт в другом
|
||||||
индексе): заводится `[goal]` в `PLAN.md`, задачи получают `--goal <новый слаг>`,
|
индексе): заводится `[goal]` в `ROADMAP.md`, задачи получают `--goal <новый слаг>`,
|
||||||
эпик закрывается с причиной-ссылкой.
|
эпик закрывается с причиной-ссылкой.
|
||||||
|
|
||||||
## Когда декомпозиция случается посреди спринта
|
## Когда декомпозиция случается посреди спринта
|
||||||
|
|||||||
@@ -13,11 +13,16 @@
|
|||||||
|
|
||||||
- **Секция:** ядро — вышла из спринта: остаток писал нерешённое в журнал
|
- **Секция:** ядро — вышла из спринта: остаток писал нерешённое в журнал
|
||||||
- **Зачем:** порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт
|
- **Зачем:** порядок канонических форм берёт меньшее в 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` переписывает списком; поле `Хук`
|
`check` называет её дрейфом, `check --fix` переписывает списком; поле `Хук`
|
||||||
@@ -59,6 +67,36 @@
|
|||||||
Тело — не план реализации и не спецификация: принятое и реализованное переезжает
|
Тело — не план реализации и не спецификация: принятое и реализованное переезжает
|
||||||
в документацию проекта, а файл задачи удаляется.
|
в документацию проекта, а файл задачи удаляется.
|
||||||
|
|
||||||
|
### Затрагивает
|
||||||
|
|
||||||
|
Перечень **границ**, которых изменение касается. Границей считается то, у чего
|
||||||
|
есть внешняя сторона и цена изменения:
|
||||||
|
|
||||||
|
- эндпоинт, команда, форма ответа, код ответа;
|
||||||
|
- таблица, поле, миграция, формат на диске, формат сообщения в очереди;
|
||||||
|
- публичный тип или функция пакета, конфиг и его образцы;
|
||||||
|
- внешний сервис или библиотека, чьё поведение становится нужным.
|
||||||
|
|
||||||
|
Ничего из этого не трогается — так и пишется: «границ не трогает, изменение
|
||||||
|
внутри одного узла». Это ответ, а не пустой раздел.
|
||||||
|
|
||||||
|
**Границы, а не замысел.** «Переписать хранилище на новый драйвер» — замысел;
|
||||||
|
`таблица points и её миграция`, `эндпоинт POST /ingest` — границы. Разница
|
||||||
|
проверяется вопросом «это можно назвать до того, как решено *как* делать?»: если
|
||||||
|
нет, строка описывает реализацию, и её место в предложении об изменении.
|
||||||
|
|
||||||
|
**Свойства репозитория сюда не пишутся** — по той же причине, что и в рамки:
|
||||||
|
имя таблицы стабильно, номер последней миграции протухает молча. Пишется
|
||||||
|
`таблица points и её миграция`, а не `миграция 0042`.
|
||||||
|
|
||||||
|
**Что из этого механизировано.** `check` и `sprint take` смотрят только на
|
||||||
|
**наличие непустого раздела**. Полнота перечня машине не видна: границу, которую
|
||||||
|
забыли назвать, она от отсутствующей не отличает. Раздела нет — отказ во взятии:
|
||||||
|
оценивать нечем.
|
||||||
|
|
||||||
|
**У идей раздела нет** — как и критериев: границы становятся известны, когда идея
|
||||||
|
превращается в задачу.
|
||||||
|
|
||||||
### Критерии приёмки
|
### Критерии приёмки
|
||||||
|
|
||||||
2–5 проверяемых утверждений **списком** `- …`, **у каждого назван оракул**. Не
|
2–5 проверяемых утверждений **списком** `- …`, **у каждого назван оракул**. Не
|
||||||
@@ -139,7 +177,7 @@
|
|||||||
`check` напоминает о нём у цели без задач замечанием — неразобранная цель
|
`check` напоминает о нём у цели без задач замечанием — неразобранная цель
|
||||||
законна и зелёного прогона не ломает; `check --fix` сам ставит его цели, у
|
законна и зелёного прогона не ломает; `check --fix` сам ставит его цели, у
|
||||||
которой задачи есть, а цель с тегом и без задач — прямое приглашение закрыть.
|
которой задачи есть, а цель с тегом и без задач — прямое приглашение закрыть.
|
||||||
- Цель живёт в `PLAN.md` и **никогда** — в `BACKLOG.md` или `SPRINT.md`.
|
- Цель живёт в `ROADMAP.md` и **никогда** — в `BACKLOG.md` или `SPRINT.md`.
|
||||||
|
|
||||||
## Слаг
|
## Слаг
|
||||||
|
|
||||||
@@ -167,7 +205,7 @@
|
|||||||
|
|
||||||
| Файл | Что отвечает | Секции |
|
| Файл | Что отвечает | Секции |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `PLAN.md` | какие есть цели, в какой очереди идут и почему | порядок (очередь значима) и темы (порядка нет) |
|
| `ROADMAP.md` | какие есть цели, в какой очереди идут и почему | порядок (очередь значима) и темы (порядка нет) |
|
||||||
| `BACKLOG.md` | что **можно взять** — только задачи | секции проекта (по умолчанию ядро/инфра) |
|
| `BACKLOG.md` | что **можно взять** — только задачи | секции проекта (по умолчанию ядро/инфра) |
|
||||||
| `SPRINT.md` | какая цель и какой набор под неё | одна: «Набор» |
|
| `SPRINT.md` | какая цель и какой набор под неё | одна: «Набор» |
|
||||||
| `REJECTED.md` | что ушло без реализации и почему | — |
|
| `REJECTED.md` | что ушло без реализации и почему | — |
|
||||||
@@ -229,6 +267,12 @@
|
|||||||
|
|
||||||
- `goal:<слаг>` — цель, которой служит задача. Обязателен: задача без цели не
|
- `goal:<слаг>` — цель, которой служит задача. Обязателен: задача без цели не
|
||||||
попадёт ни в один спринт.
|
попадёт ни в один спринт.
|
||||||
|
- `kind:<род>` — род работы: `feature` | `fix` | `chore` | `research`. Словарь
|
||||||
|
**закрыт**, значение ровно одно. Обязателен у задачи (без него `sprint take`
|
||||||
|
откажет), у цели запрещён, у идеи и эпика необязателен. Ставится
|
||||||
|
`add --kind` / `edit --kind`; `--kind` заменяет прежнее значение, а не
|
||||||
|
добавляет второе. Смысл рода и почему он тегом, а не префиксом — в SKILL.md,
|
||||||
|
раздел «Род работы».
|
||||||
- `question` — в файле есть неразобранный раздел «Вопросы».
|
- `question` — в файле есть неразобранный раздел «Вопросы».
|
||||||
- `sprint:<слаг>` — задача заведена в этом спринте; по нему отбирается первая
|
- `sprint:<слаг>` — задача заведена в этом спринте; по нему отбирается первая
|
||||||
порция разбора («урожай спринта»). **Ставится сам**: слаг спринта заводит
|
порция разбора («урожай спринта»). **Ставится сам**: слаг спринта заводит
|
||||||
@@ -248,17 +292,22 @@
|
|||||||
|
|
||||||
## Тест «готова к взятию»
|
## Тест «готова к взятию»
|
||||||
|
|
||||||
Задача готова, если из файла отвечаются три вопроса:
|
Задача готова, если из файла отвечаются четыре вопроса:
|
||||||
|
|
||||||
1. **Что станет наблюдаемо иначе**, когда она сделана — снаружи: пользователю,
|
1. **Что станет наблюдаемо иначе**, когда она сделана — снаружи: пользователю,
|
||||||
владельцу сервиса или разработчику. «Отрефакторить X» — не ответ; «перестанет
|
владельцу сервиса или разработчику. «Отрефакторить X» — не ответ; «перестанет
|
||||||
ломаться Y при Z» — ответ.
|
ломаться Y при Z» — ответ. **У `kind:chore` адресат — разработчик, и это
|
||||||
2. **По чему видно, что закончено** — критерии приёмки с оракулами.
|
законно**: «уедет последний вызов устаревшего API» — ответ, а не отговорка.
|
||||||
3. **Какой цели она служит** — тег `goal:` и одна строка «почему именно этой».
|
Род объявлен как раз затем, чтобы такие задачи не выдумывали себе
|
||||||
|
пользовательскую пользу.
|
||||||
|
2. **Каких границ это касается** — раздел «Затрагивает». Без него задачу нельзя
|
||||||
|
оценить: остаётся судить по длине текста.
|
||||||
|
3. **По чему видно, что закончено** — критерии приёмки с оракулами.
|
||||||
|
4. **Какой цели она служит** — тег `goal:` и одна строка «почему именно этой».
|
||||||
|
|
||||||
Не отвечается первый или второй вопрос → это **идея** (`[idea]`), её место в
|
Не отвечается первый, второй или третий вопрос → это **идея** (`[idea]`), её
|
||||||
штурме. Не отвечается третий → либо цель есть и не проставлена, либо задача не
|
место в штурме. Не отвечается четвёртый → либо цель есть и не проставлена, либо
|
||||||
служит ничему — тогда её не надо заводить.
|
задача не служит ничему — тогда её не надо заводить.
|
||||||
|
|
||||||
Отвечается всё, но задача не делается одним заходом и не мерджится целиком →
|
Отвечается всё, но задача не делается одним заходом и не мерджится целиком →
|
||||||
**эпик** (`[epic]`), сперва декомпозиция. Эпик временен и исчезает после
|
**эпик** (`[epic]`), сперва декомпозиция. Эпик временен и исчезает после
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ av-dev, и подгоняется под него проект. Имена вн
|
|||||||
|
|
||||||
docs/tasks/
|
docs/tasks/
|
||||||
items/ задачи и цели файлами, <slug>.md
|
items/ задачи и цели файлами, <slug>.md
|
||||||
PLAN.md оглавление целей: порядок (значим) и темы (без порядка)
|
ROADMAP.md оглавление целей: порядок (значим) и темы (без порядка)
|
||||||
BACKLOG.md что можно взять — только задачи, целей здесь нет
|
BACKLOG.md что можно взять — только задачи, целей здесь нет
|
||||||
SPRINT.md текущий спринт: цель, набор, дата, слаг
|
SPRINT.md текущий спринт: цель, набор, дата, слаг
|
||||||
REJECTED.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 check [--dir DIR] [--fix]
|
||||||
tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag a,b]
|
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]
|
tasks.py add --slug S --title T [--type goal|idea|epic] [--section S]
|
||||||
[--goal G] [--why H] [--reason R] [--tag a,b] [--dir DIR]
|
[--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]
|
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]
|
[--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 move S --section S [--reason R] [--after S | --first] [--dir DIR]
|
||||||
tasks.py close S (--reason R | --implemented) [--dir DIR]
|
tasks.py close S (--reason R | --implemented) [--dir DIR]
|
||||||
@@ -96,21 +103,22 @@ EXIT_INTERNAL = 4
|
|||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
"items": "items",
|
"items": "items",
|
||||||
"backlog": "BACKLOG.md",
|
"backlog": "BACKLOG.md",
|
||||||
"plan": "PLAN.md",
|
"roadmap": "ROADMAP.md",
|
||||||
"sprint": "SPRINT.md",
|
"sprint": "SPRINT.md",
|
||||||
"rejected": "REJECTED.md",
|
"rejected": "REJECTED.md",
|
||||||
"sprint_section": "Набор",
|
"sprint_section": "Набор",
|
||||||
"criteria_heading": "Критерии приёмки",
|
"criteria_heading": "Критерии приёмки",
|
||||||
|
"surface_heading": "Затрагивает",
|
||||||
"questions_heading": "Вопросы",
|
"questions_heading": "Вопросы",
|
||||||
"oracle_word": "оракул",
|
"oracle_word": "оракул",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Какие ключи конфига — имена файлов и каталогов (их существование сверяется
|
# Какие ключи конфига — имена файлов и каталогов (их существование сверяется
|
||||||
# с диском первым делом, иначе кривой ключ выглядит как пропавший файл).
|
# с диском первым делом, иначе кривой ключ выглядит как пропавший файл).
|
||||||
PATH_KEYS = ("items", "backlog", "plan", "sprint", "rejected")
|
PATH_KEYS = ("items", "backlog", "roadmap", "sprint", "rejected")
|
||||||
|
|
||||||
DEFAULT_SECTIONS = "ядро,инфра"
|
DEFAULT_SECTIONS = "ядро,инфра"
|
||||||
DEFAULT_PLAN_SECTIONS = "порядок,темы"
|
DEFAULT_ROADMAP_SECTIONS = "порядок,темы"
|
||||||
|
|
||||||
# Мета — список под заголовком, поле на строку. Старая форма (все поля одной
|
# Мета — список под заголовком, поле на строку. Старая форма (все поля одной
|
||||||
# строкой через `·`) читается по-прежнему: у проектов на диске лежат файлы в
|
# строкой через `·`) читается по-прежнему: у проектов на диске лежат файлы в
|
||||||
@@ -177,6 +185,13 @@ TAKEABLE = (PLAIN_TYPE,) # что вообще можно взять
|
|||||||
QUESTION_TAG = "question"
|
QUESTION_TAG = "question"
|
||||||
GOAL_TAG = "goal:"
|
GOAL_TAG = "goal:"
|
||||||
SPRINT_TAG = "sprint:"
|
SPRINT_TAG = "sprint:"
|
||||||
|
# Род работы — вторая ось типа. Первая («тип записи»: goal/idea/epic/task)
|
||||||
|
# отвечает «что это за запись», вторая — «какого рода работа». Смешивать их в
|
||||||
|
# одном префиксе нельзя: идея *про* функцию, эпик *и есть* функция, и одно
|
||||||
|
# значение на два вопроса не отвечает. Дом рода — тег, потому что теги здесь и
|
||||||
|
# есть единственный механизм разметки, а `list --tag` уже умеет отбирать.
|
||||||
|
KIND_TAG = "kind:"
|
||||||
|
KINDS = ("feature", "fix", "chore", "research")
|
||||||
DECOMPOSED_TAG = "decomposed" # цель разложена на задачи (см. «Статус цели»)
|
DECOMPOSED_TAG = "decomposed" # цель разложена на задачи (см. «Статус цели»)
|
||||||
STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check
|
STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check
|
||||||
CRITERIA_MIN, CRITERIA_MAX = 2, 5 # сколько утверждений в критериях приёмки
|
CRITERIA_MIN, CRITERIA_MAX = 2, 5 # сколько утверждений в критериях приёмки
|
||||||
@@ -231,6 +246,20 @@ def bad_tags(raw: str | None) -> str | None:
|
|||||||
return 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]:
|
def split_tags(raw: str | None) -> list[str]:
|
||||||
return [t.strip().lower() for t in (raw or "").split(",") if t.strip()]
|
return [t.strip().lower() for t in (raw or "").split(",") if t.strip()]
|
||||||
|
|
||||||
@@ -315,7 +344,7 @@ class Layout:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def indexes(self) -> tuple[str, ...]:
|
def indexes(self) -> tuple[str, ...]:
|
||||||
return ("backlog", "sprint", "plan")
|
return ("backlog", "sprint", "roadmap")
|
||||||
|
|
||||||
|
|
||||||
def load_config(root: Path) -> dict:
|
def load_config(root: Path) -> dict:
|
||||||
@@ -356,6 +385,14 @@ def _read_json(path: Path) -> dict:
|
|||||||
|
|
||||||
def _validate_config(data: dict, path: Path) -> dict:
|
def _validate_config(data: dict, path: Path) -> dict:
|
||||||
unknown = set(data) - set(DEFAULTS)
|
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:
|
if unknown:
|
||||||
raise Env(f"{path}: неизвестные ключи: {', '.join(sorted(unknown))}"
|
raise Env(f"{path}: неизвестные ключи: {', '.join(sorted(unknown))}"
|
||||||
f" (известны: {', '.join(sorted(DEFAULTS))})")
|
f" (известны: {', '.join(sorted(DEFAULTS))})")
|
||||||
@@ -391,7 +428,7 @@ def config_problems(lay: Layout) -> list[str]:
|
|||||||
out = []
|
out = []
|
||||||
if not lay.items.is_dir():
|
if not lay.items.is_dir():
|
||||||
out.append(f"{where}: items = «{lay.cfg['items']}» → {lay.items} — каталога нет")
|
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)
|
p = lay.index(kind)
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
out.append(f"{where}: {kind} = «{lay.cfg[kind]}» → {p} — файла нет")
|
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")
|
text = path.read_text(encoding="utf-8")
|
||||||
lines = text.splitlines()
|
lines = text.splitlines()
|
||||||
title = lines[0].removeprefix("#").strip() if lines and lines[0].startswith("#") else ""
|
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)):
|
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). Порядок полей свободный:
|
# Мета — блок под заголовком (task-format.md). Порядок полей свободный:
|
||||||
# секция распознаётся, где бы она ни стояла.
|
# секция распознаётся, где бы она ни стояла.
|
||||||
section, reason, why, tags, legacy = "", "", "", [], False
|
section, reason, why, tags, legacy = "", "", "", [], False
|
||||||
@@ -543,8 +580,10 @@ def parse_task(path: Path) -> dict:
|
|||||||
elif key in ("теги", "tags"):
|
elif key in ("теги", "tags"):
|
||||||
tags = [t.strip().lower() for t in value.split(",") if t.strip()]
|
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)), "")
|
goal = next((t[len(GOAL_TAG):] for t in tags if t.startswith(GOAL_TAG)), "")
|
||||||
return {"title": title, "bare": bare, "type": kind, "section": section,
|
kind = next((t[len(KIND_TAG):] for t in tags if t.startswith(KIND_TAG)), "")
|
||||||
"reason": reason, "why": why, "tags": tags, "goal": goal, "path": path,
|
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)}
|
"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:
|
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]:
|
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
|
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:
|
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']} указывает на цель,"
|
errors.append(f"{name}: тег {GOAL_TAG}{task['goal']} указывает на цель,"
|
||||||
f" которой нет в {lay.cfg['items']}/")
|
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. Спринт: задача с открытым вопросом в набор не берётся, и судит об
|
# 4. Спринт: задача с открытым вопросом в набор не берётся, и судит об
|
||||||
# этом раздел, а не тег.
|
# этом раздел, а не тег.
|
||||||
if place == "sprint":
|
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:
|
if goal_of_sprint and task["goal"] and task["goal"] != goal_of_sprint:
|
||||||
errors.append(f"{name}: цель задачи «{task['goal']}» не цель спринта"
|
errors.append(f"{name}: цель задачи «{task['goal']}» не цель спринта"
|
||||||
f" «{goal_of_sprint}» — набор служит одной цели")
|
f" «{goal_of_sprint}» — набор служит одной цели")
|
||||||
e, n = criteria_verdict(lay, task)
|
if not task["kind"]:
|
||||||
errors += [f"{x} (в спринте)" for x in e]
|
errors.append(f"{name}: в спринте без рода работы"
|
||||||
notes += n
|
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» производен от раздела: раздел — факт, тег — метка.
|
# 5. Тег «question» производен от раздела: раздел — факт, тег — метка.
|
||||||
has_q_section = questions_open(lay, task)
|
has_q_section = questions_open(lay, task)
|
||||||
@@ -956,6 +1033,8 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int:
|
|||||||
continue
|
continue
|
||||||
if a.goal and t["goal"] != a.goal.lower():
|
if a.goal and t["goal"] != a.goal.lower():
|
||||||
continue
|
continue
|
||||||
|
if a.kind and t["kind"] != a.kind.lower():
|
||||||
|
continue
|
||||||
if a.questions and not questions_open(lay, t):
|
if a.questions and not questions_open(lay, t):
|
||||||
continue
|
continue
|
||||||
rows.append(t)
|
rows.append(t)
|
||||||
@@ -970,11 +1049,12 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
for t in rows:
|
for t in rows:
|
||||||
touched = f"{t.get('touched', ''):<11}" if a.stale else ""
|
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 ""
|
goal = f" →{t['goal']}" if t["goal"] and not a.goal else ""
|
||||||
flag = " ?" if questions_open(lay, t) else " "
|
flag = " ?" if questions_open(lay, t) else " "
|
||||||
print(f"{touched}{t['place']:<8}{t['section']:<8}{flag} "
|
print(f"{touched}{t['place']:<8}{t['section']:<8}{kind}{flag} "
|
||||||
f"{t['path'].stem:<44} {kind}{t['bare']}{goal}")
|
f"{t['path'].stem:<44} {rtype}{t['bare']}{goal}")
|
||||||
print(f"\nвсего: {len(rows)}")
|
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: str | None = None, first: bool = False) -> None:
|
||||||
"""Вставляет строку в секцию: по умолчанию в конец, --after <слаг> — следом
|
"""Вставляет строку в секцию: по умолчанию в конец, --after <слаг> — следом
|
||||||
за указанной строкой, --first — первой. Порядок нужен только упорядоченной
|
за указанной строкой, --first — первой. Порядок нужен только упорядоченной
|
||||||
части плана; в беклоге он значения не имеет."""
|
части роадмапа; в беклоге он значения не имеет."""
|
||||||
hi, _ = find_section(lines, section)
|
hi, _ = find_section(lines, section)
|
||||||
if hi is None:
|
if hi is None:
|
||||||
raise Usage(f"секции «{section}» в индексе нет")
|
raise Usage(f"секции «{section}» в индексе нет")
|
||||||
@@ -1125,6 +1205,11 @@ def body_template(kind: str, lay: Layout) -> str:
|
|||||||
return ("<!-- что за идея, откуда взялась, чем может быть полезна;"
|
return ("<!-- что за идея, откуда взялась, чем может быть полезна;"
|
||||||
" критериев у идеи нет — потому она и идея -->\n")
|
" критериев у идеи нет — потому она и идея -->\n")
|
||||||
return ("<!-- задача в одной фразе: что станет наблюдаемо иначе -->\n\n"
|
return ("<!-- задача в одной фразе: что станет наблюдаемо иначе -->\n\n"
|
||||||
|
f"## {lay.cfg['surface_heading']}\n\n"
|
||||||
|
"<!-- границы, которых изменение касается: эндпоинт или команда,"
|
||||||
|
" таблица и миграция, формат на диске, публичный тип пакета,"
|
||||||
|
" внешний сервис. Названы границы, а не то, как они изменятся:"
|
||||||
|
" план реализации живёт в предложении, а не здесь -->\n\n"
|
||||||
f"## {lay.cfg['criteria_heading']}\n\n"
|
f"## {lay.cfg['criteria_heading']}\n\n"
|
||||||
f"<!-- {CRITERIA_MIN}–{CRITERIA_MAX} проверяемых утверждений списком,"
|
f"<!-- {CRITERIA_MIN}–{CRITERIA_MAX} проверяемых утверждений списком,"
|
||||||
f" у каждого назван {lay.cfg['oracle_word']} -->\n\n"
|
f" у каждого назван {lay.cfg['oracle_word']} -->\n\n"
|
||||||
@@ -1135,17 +1220,18 @@ def body_template(kind: str, lay: Layout) -> str:
|
|||||||
|
|
||||||
def cmd_add(lay: Layout, a: argparse.Namespace) -> int:
|
def cmd_add(lay: Layout, a: argparse.Namespace) -> int:
|
||||||
for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_why(a.why),
|
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:
|
if err:
|
||||||
raise Usage(err)
|
raise Usage(err)
|
||||||
if not a.title.strip():
|
if not a.title.strip():
|
||||||
raise Usage("пустой заголовок")
|
raise Usage("пустой заголовок")
|
||||||
kind = (a.type or "").strip().lower()
|
rtype = (a.type or "").strip().lower()
|
||||||
path = lay.items / f"{a.slug}.md"
|
path = lay.items / f"{a.slug}.md"
|
||||||
if path.exists():
|
if path.exists():
|
||||||
raise Usage(f"{path.name} уже существует — дедуп: допиши в него, а не заводи новый")
|
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))
|
lines = read_lines(lay.index(target))
|
||||||
if not lines:
|
if not lines:
|
||||||
raise Usage(f"нет индекса {lay.name(target)} — прогони tasks.py init")
|
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}"]
|
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():
|
if not (lay.items / f"{a.goal}.md").exists():
|
||||||
print(f" внимание: цели {a.goal}.md нет — заведи её (--type goal) или поправь тег")
|
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(" без цели: задача вне цели не попадёт ни в один спринт —"
|
print(" без цели: задача вне цели не попадёт ни в один спринт —"
|
||||||
f" проставь `tasks.py edit {a.slug} --goal <слаг>`")
|
f" проставь `tasks.py edit {a.slug} --goal <слаг>`")
|
||||||
# Урожай спринта метится сам: тег, который никто не ставит, не отбирает
|
# Урожай спринта метится сам: тег, который никто не ставит, не отбирает
|
||||||
# первую порцию переоценки, а именно на ней держится правило «сперва урожай».
|
# первую порцию переоценки, а именно на ней держится правило «сперва урожай».
|
||||||
sslug = sprint_slug(lay)
|
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}")
|
tags.append(f"{SPRINT_TAG}{sslug}")
|
||||||
if QUESTION_TAG in tags:
|
if QUESTION_TAG in tags:
|
||||||
print(f" тег «{QUESTION_TAG}»: не забудь раздел «{lay.cfg['questions_heading']}» в теле")
|
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)
|
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 ""))
|
insert_entry(lines, section, entry_line(lay, title_full, a.slug, a.why or ""))
|
||||||
|
|
||||||
plan = Plan()
|
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.index(lay, target, lines)
|
||||||
plan.commit()
|
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:
|
def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
|
||||||
for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_why(a.why),
|
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:
|
if err:
|
||||||
raise Usage(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)):
|
if all(v is None for v in (a.title, a.why, a.type, a.goal, a.kind,
|
||||||
raise Usage("нечего менять: дай --title, --why, --type, --goal, --add-tag или --rm-tag")
|
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"
|
path = lay.items / f"{a.slug}.md"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/")
|
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}"]
|
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():
|
if not (lay.items / f"{a.goal}.md").exists():
|
||||||
print(f" внимание: цели {a.goal}.md нет — заведи её или поправь тег")
|
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
|
why = task["why"] if a.why is None else a.why
|
||||||
section = task["section"]
|
section = task["section"]
|
||||||
@@ -1303,7 +1399,8 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
|
|||||||
plan.commit()
|
plan.commit()
|
||||||
|
|
||||||
changed = [n for n, v in (("заголовок", a.title), ("зачем", a.why), ("тип", a.type),
|
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]
|
if v is not None]
|
||||||
print(f"{a.slug}: обновлено ({', '.join(changed)})")
|
print(f"{a.slug}: обновлено ({', '.join(changed)})")
|
||||||
if len(places) > 1:
|
if len(places) > 1:
|
||||||
@@ -1592,10 +1689,16 @@ def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int:
|
|||||||
raise Usage(f"{slug}: тег «{QUESTION_TAG}» стоит, а раздела"
|
raise Usage(f"{slug}: тег «{QUESTION_TAG}» стоит, а раздела"
|
||||||
f" «{lay.cfg['questions_heading']}» нет — либо вопрос записан не туда,"
|
f" «{lay.cfg['questions_heading']}» нет — либо вопрос записан не туда,"
|
||||||
f" либо тег пора снять: `edit {slug} --rm-tag {QUESTION_TAG}`")
|
f" либо тег пора снять: `edit {slug} --rm-tag {QUESTION_TAG}`")
|
||||||
errs, notes = criteria_verdict(lay, t)
|
if not t["kind"]:
|
||||||
if errs:
|
raise Usage(f"{slug}: род работы не назван —"
|
||||||
raise Usage("; ".join(errs))
|
f" `edit {slug} --kind {'|'.join(KINDS)}`."
|
||||||
warn += notes
|
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)
|
ei = find_entry_index(backlog_lines, slug)
|
||||||
if ei is None:
|
if ei is None:
|
||||||
raise Usage(f"{slug}: строки в {lay.name('backlog')} нет"
|
raise Usage(f"{slug}: строки в {lay.name('backlog')} нет"
|
||||||
@@ -1699,7 +1802,7 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
|
|||||||
меты файла;
|
меты файла;
|
||||||
- «зачем», оставшееся только в индексе, — переносится в файл (миграция со
|
- «зачем», оставшееся только в индексе, — переносится в файл (миграция со
|
||||||
старого формата: другого экземпляра нет, неоднозначности тоже);
|
старого формата: другого экземпляра нет, неоднозначности тоже);
|
||||||
- строка в чужом индексе (цель в беклоге, задача в плане) — переносится в
|
- строка в чужом индексе (цель в беклоге, задача в роадмапе) — переносится в
|
||||||
домашний. Задача лежит ровно в одном индексе и не в том, выбирать не из
|
домашний. Задача лежит ровно в одном индексе и не в том, выбирать не из
|
||||||
чего: истина в типе, а тип в файле;
|
чего: истина в типе, а тип в файле;
|
||||||
- цель, у которой есть задачи, помечается `decomposed`.
|
- цель, у которой есть задачи, помечается `decomposed`.
|
||||||
@@ -1853,7 +1956,7 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
|
|||||||
|
|
||||||
# --- init ---
|
# --- 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]:
|
cfg: dict) -> dict[Path, str]:
|
||||||
out: dict[Path, str] = {}
|
out: dict[Path, str] = {}
|
||||||
if cfg:
|
if cfg:
|
||||||
@@ -1875,20 +1978,20 @@ def init_files(lay: Layout, sections: list[str], plan_sections: list[str],
|
|||||||
"# Беклог\n\n"
|
"# Беклог\n\n"
|
||||||
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
|
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
|
||||||
"+ строка здесь. Целей тут нет — они в "
|
"+ строка здесь. Целей тут нет — они в "
|
||||||
f"[{lay.name('plan')}]({lay.name('plan')}): беклог — то, что берут,\n"
|
f"[{lay.name('roadmap')}]({lay.name('roadmap')}): беклог — то, что берут,\n"
|
||||||
"план — то, подо что берут. Порядка внутри секции нет: «что делать\n"
|
"роадмап — то, подо что берут. Порядка внутри секции нет: «что делать\n"
|
||||||
f"дальше» отвечает набор спринта. Ведётся скиллом `tasks`.\n\n"
|
f"дальше» отвечает набор спринта. Ведётся скиллом `tasks`.\n\n"
|
||||||
"Секции «блокеры» здесь нет и не заводится: блокер — это состояние\n"
|
"Секции «блокеры» здесь нет и не заводится: блокер — это состояние\n"
|
||||||
"(спринт не может продолжаться ни одной задачей), оно живёт до ответа\n"
|
"(спринт не может продолжаться ни одной задачей), оно живёт до ответа\n"
|
||||||
"человека, а его следы — вопросами в файлах задач.\n\n"
|
"человека, а его следы — вопросами в файлах задач.\n\n"
|
||||||
+ "".join(f"## {s}\n\n" for s in sections))
|
+ "".join(f"## {s}\n\n" for s in sections))
|
||||||
out[lay.index("plan")] = (
|
out[lay.index("roadmap")] = (
|
||||||
"# План\n\n"
|
"# Роадмап\n\n"
|
||||||
f"Оглавление целей. Цель — файл `[goal]` в `{lay.cfg['items']}/`; её задачи\n"
|
f"Оглавление целей. Цель — файл `[goal]` в `{lay.cfg['items']}/`; её задачи\n"
|
||||||
"здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.\n"
|
"здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.\n"
|
||||||
f"В первой секции («{plan_sections[0]}») очередь значима и обосновывается\n"
|
f"В первой секции («{roadmap_sections[0]}») очередь значима и обосновывается\n"
|
||||||
"прозой; в остальных порядка нет — это тематические цели.\n\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("sprint")] = empty_sprint(lay)
|
||||||
out[lay.index("rejected")] = (
|
out[lay.index("rejected")] = (
|
||||||
"# Ушедшее без реализации\n\n"
|
"# Ушедшее без реализации\n\n"
|
||||||
@@ -1911,14 +2014,14 @@ def uniq_sections(raw: str) -> list[str]:
|
|||||||
def cmd_init(root: Path, a: argparse.Namespace) -> int:
|
def cmd_init(root: Path, a: argparse.Namespace) -> int:
|
||||||
if not dir_within_cwd(root):
|
if not dir_within_cwd(root):
|
||||||
raise Usage(f"--dir вне рабочего каталога: {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}
|
("sprint", a.sprint), ("rejected", a.rejected)) if v}
|
||||||
lay = Layout(root, cfg)
|
lay = Layout(root, cfg)
|
||||||
if lay.index("backlog").exists():
|
if lay.index("backlog").exists():
|
||||||
raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён")
|
raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён")
|
||||||
|
|
||||||
sections, plan_sections = uniq_sections(a.sections), uniq_sections(a.plan_sections)
|
sections, roadmap_sections = uniq_sections(a.sections), uniq_sections(a.roadmap_sections)
|
||||||
if not sections or not plan_sections:
|
if not sections or not roadmap_sections:
|
||||||
raise Usage("пустой список секций")
|
raise Usage("пустой список секций")
|
||||||
blockers = [s for s in sections if s.lower() in BLOCKER_SECTIONS]
|
blockers = [s for s in sections if s.lower() in BLOCKER_SECTIONS]
|
||||||
if blockers:
|
if blockers:
|
||||||
@@ -1930,11 +2033,12 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
lay.items.mkdir(parents=True, exist_ok=True)
|
lay.items.mkdir(parents=True, exist_ok=True)
|
||||||
plan = Plan()
|
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.file(path, text)
|
||||||
plan.commit()
|
plan.commit()
|
||||||
print(f"каталог задач заведён: {root}")
|
print(f"каталог задач заведён: {root}")
|
||||||
print(f" секции беклога: {', '.join(sections)}; части плана: {', '.join(plan_sections)}")
|
print(f" секции беклога: {', '.join(sections)};"
|
||||||
|
f" части роадмапа: {', '.join(roadmap_sections)}")
|
||||||
if cfg:
|
if cfg:
|
||||||
pm = (root / PM_CONFIG_REL).resolve()
|
pm = (root / PM_CONFIG_REL).resolve()
|
||||||
print(f" имена частей записаны в {pm if pm.is_file() else root / CONFIG_NAME}")
|
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", [])
|
rejected += sc.get("rejected", [])
|
||||||
unclassified += sc.get("unclassified", [])
|
unclassified += sc.get("unclassified", [])
|
||||||
goals += [{"slug": "", "title": g["title"],
|
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"),
|
"from": g["from"], "step": g.get("step"), "done": g.get("done"),
|
||||||
"body": f"Выведена из шага «{g['title']}» ({g['from']})."
|
"body": f"Выведена из шага «{g['title']}» ({g['from']})."
|
||||||
+ ("\n\nШаг помечен закрытым — цель, скорее всего,"
|
+ ("\n\nШаг помечен закрытым — цель, скорее всего,"
|
||||||
@@ -2125,7 +2229,7 @@ def cmd_adopt_scan(a: argparse.Namespace) -> int:
|
|||||||
"sources": [str(s) for s in sources],
|
"sources": [str(s) for s in sources],
|
||||||
"path_map": path_map,
|
"path_map": path_map,
|
||||||
"sections_backlog": uniq_sections(a.sections),
|
"sections_backlog": uniq_sections(a.sections),
|
||||||
"sections_plan": uniq_sections(a.plan_sections),
|
"sections_roadmap": uniq_sections(a.roadmap_sections),
|
||||||
"section_map": {},
|
"section_map": {},
|
||||||
"goals": goals,
|
"goals": goals,
|
||||||
"items": items,
|
"items": items,
|
||||||
@@ -2242,9 +2346,9 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
|||||||
raise Usage(f"target вне рабочего каталога: {root}")
|
raise Usage(f"target вне рабочего каталога: {root}")
|
||||||
lay = Layout(root, {})
|
lay = Layout(root, {})
|
||||||
sections = pl.get("sections_backlog") or uniq_sections(DEFAULT_SECTIONS)
|
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_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] = []
|
problems: list[str] = []
|
||||||
@@ -2261,9 +2365,9 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
|||||||
if g["slug"] in slugs:
|
if g["slug"] in slugs:
|
||||||
problems.append(f"слаг «{g['slug']}» встречается дважды")
|
problems.append(f"слаг «{g['slug']}» встречается дважды")
|
||||||
slugs.add(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', '')}»"
|
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")}
|
goal_slugs = {g["slug"] for g in pl.get("goals", []) if g.get("slug")}
|
||||||
for it in pl.get("items", []):
|
for it in pl.get("items", []):
|
||||||
slug = it.get("slug") or it.get("old_slug")
|
slug = it.get("slug") or it.get("old_slug")
|
||||||
@@ -2290,10 +2394,10 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
# --- план записи ---
|
# --- план записи ---
|
||||||
wr = Plan()
|
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)
|
wr.file(path, text)
|
||||||
backlog_lines = init_files(lay, sections, plan_sections, {})[lay.index("backlog")].splitlines()
|
backlog_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("backlog")].splitlines()
|
||||||
plan_lines = init_files(lay, sections, plan_sections, {})[lay.index("plan")].splitlines()
|
roadmap_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("roadmap")].splitlines()
|
||||||
|
|
||||||
renames: dict[str, str] = {}
|
renames: dict[str, str] = {}
|
||||||
for g in pl.get("goals", []):
|
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",
|
wr.file(lay.items / f"{g['slug']}.md",
|
||||||
f"# {title}\n\n{meta}\n\n{body}\n\n## Завершение\n\n"
|
f"# {title}\n\n{meta}\n\n{body}\n\n## Завершение\n\n"
|
||||||
f"<!-- по чему видно, что цель достигнута -->\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", "")))
|
entry_line(lay, title, g["slug"], g.get("why", "")))
|
||||||
|
|
||||||
for it in pl.get("items", []):
|
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", "")))
|
entry_line(lay, title, slug, it.get("why", "")))
|
||||||
|
|
||||||
if pl.get("rejected"):
|
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 = []
|
body = []
|
||||||
for line in pl["rejected"]:
|
for line in pl["rejected"]:
|
||||||
line = re.sub(r"Был приоритет:", "Была секция:", line)
|
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("rejected"), head + "\n".join(body) + "\n")
|
||||||
|
|
||||||
wr.file(lay.index("backlog"), "\n".join(backlog_lines))
|
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:
|
if a.dry_run:
|
||||||
print(f"пробный прогон: записалось бы файлов {len(wr.writes)},"
|
print(f"пробный прогон: записалось бы файлов {len(wr.writes)},"
|
||||||
@@ -2422,7 +2526,8 @@ def main() -> int:
|
|||||||
p.add_argument("--tag", help="тег или список через запятую (нужны ВСЕ):"
|
p.add_argument("--tag", help="тег или список через запятую (нужны ВСЕ):"
|
||||||
" goal:<слаг>, question, sprint:<слаг>")
|
" goal:<слаг>, question, sprint:<слаг>")
|
||||||
p.add_argument("--goal", help="задачи одной цели (перечень выводится, а не хранится)")
|
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.add_argument("--questions", action="store_true", help="только с открытым вопросом")
|
||||||
|
|
||||||
p = sub.add_parser("add", help="завести задачу, идею или цель")
|
p = sub.add_parser("add", help="завести задачу, идею или цель")
|
||||||
@@ -2432,27 +2537,29 @@ def main() -> int:
|
|||||||
p.add_argument("--type", choices=TYPES)
|
p.add_argument("--type", choices=TYPES)
|
||||||
p.add_argument("--section")
|
p.add_argument("--section")
|
||||||
p.add_argument("--goal", help="слаг цели → тег goal:<слаг>")
|
p.add_argument("--goal", help="слаг цели → тег goal:<слаг>")
|
||||||
|
p.add_argument("--kind", choices=KINDS, help="род работы → тег kind:<род>")
|
||||||
p.add_argument("--why")
|
p.add_argument("--why")
|
||||||
p.add_argument("--reason")
|
p.add_argument("--reason")
|
||||||
p.add_argument("--tag")
|
p.add_argument("--tag")
|
||||||
|
|
||||||
p = sub.add_parser("edit", help="сменить заголовок/«зачем»/тип/цель/теги")
|
p = sub.add_parser("edit", help="сменить заголовок/«зачем»/тип/род/цель/теги")
|
||||||
p.add_argument("slug")
|
p.add_argument("slug")
|
||||||
p.add_argument("--title")
|
p.add_argument("--title")
|
||||||
p.add_argument("--why")
|
p.add_argument("--why")
|
||||||
p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE))
|
p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE))
|
||||||
p.add_argument("--goal", help="заменить тег goal:<слаг>")
|
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("--add-tag", dest="add_tag")
|
||||||
p.add_argument("--rm-tag", dest="rm_tag")
|
p.add_argument("--rm-tag", dest="rm_tag")
|
||||||
p.add_argument("--section", help="только вместе со сменой типа, меняющей индекс")
|
p.add_argument("--section", help="только вместе со сменой типа, меняющей индекс")
|
||||||
p.add_argument("--dir")
|
p.add_argument("--dir")
|
||||||
|
|
||||||
p = sub.add_parser("move", help="перенести в другую секцию беклога или часть плана")
|
p = sub.add_parser("move", help="перенести в другую секцию беклога или часть роадмапа")
|
||||||
p.add_argument("slug")
|
p.add_argument("slug")
|
||||||
p.add_argument("--section", required=True)
|
p.add_argument("--section", required=True)
|
||||||
p.add_argument("--reason")
|
p.add_argument("--reason")
|
||||||
g = p.add_mutually_exclusive_group()
|
g = p.add_mutually_exclusive_group()
|
||||||
g.add_argument("--after", help="встать следом за этим слагом (упорядоченная часть плана)")
|
g.add_argument("--after", help="встать следом за этим слагом (упорядоченная часть роадмапа)")
|
||||||
g.add_argument("--first", action="store_true")
|
g.add_argument("--first", action="store_true")
|
||||||
p.add_argument("--dir")
|
p.add_argument("--dir")
|
||||||
|
|
||||||
@@ -2490,10 +2597,10 @@ def main() -> int:
|
|||||||
p = sub.add_parser("init", help="завести каталог задач в новом проекте")
|
p = sub.add_parser("init", help="завести каталог задач в новом проекте")
|
||||||
p.add_argument("--dir")
|
p.add_argument("--dir")
|
||||||
p.add_argument("--sections", default=DEFAULT_SECTIONS)
|
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("--items")
|
||||||
p.add_argument("--backlog")
|
p.add_argument("--backlog")
|
||||||
p.add_argument("--plan")
|
p.add_argument("--roadmap")
|
||||||
p.add_argument("--sprint")
|
p.add_argument("--sprint")
|
||||||
p.add_argument("--rejected")
|
p.add_argument("--rejected")
|
||||||
|
|
||||||
@@ -2504,7 +2611,7 @@ def main() -> int:
|
|||||||
s.add_argument("--target", default="docs/tasks")
|
s.add_argument("--target", default="docs/tasks")
|
||||||
s.add_argument("--out", default="tasks-adopt-plan.json")
|
s.add_argument("--out", default="tasks-adopt-plan.json")
|
||||||
s.add_argument("--sections", default=DEFAULT_SECTIONS)
|
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 = asub.add_parser("apply", help="записать каталог по подтверждённой карте")
|
||||||
s.add_argument("--plan", required=True)
|
s.add_argument("--plan", required=True)
|
||||||
s.add_argument("--refs", nargs="*", help="файлы и каталоги, где чинить ссылки на слаги")
|
s.add_argument("--refs", nargs="*", help="файлы и каталоги, где чинить ссылки на слаги")
|
||||||
|
|||||||
Reference in New Issue
Block a user