av-dev-pipeline: бриф удалён, проходы читают документы канона напрямую

- удалены скилл project-brief и контракт брифа; вместо них references/
  project-facts.md — карта «что нужно проходу → где лежит» и таблица
  поразрядной деградации по документам
- девять charter'ов, review-pipeline, task-pipeline и task-batch переписаны
  на пути канона; OpenSpec стал объявленной предпосылкой без ветки деградации
- шаг синка документации переписан в построчный доклад, закрытие задачи —
  вызовом скилла av-dev-pm:tasks вместо строки-слота из CLAUDE.md
- по находкам ревью: docs.py звал tasks.py из чужого каталога и выдавал его
  отказ окружения за дрейф; сверка миграций не видела рабочее дерево;
  плейсхолдер краснел вместо замечания; сверка capability проходила по
  совпадению с именем пакета; tasks.py не читал docs/.pm.json; скилл docs
  пересказывал канон в пяти местах
This commit is contained in:
av
2026-08-03 14:28:55 +03:00
parent ad1779b81f
commit 9cef45252c
26 changed files with 687 additions and 1232 deletions
+22 -8
View File
@@ -17,7 +17,10 @@ description: Привести проект к канону документов
пересказывается: два описания одной раскладки разъедутся, и работать будет то,
которое прочитали последним. Прочитай его **до** первой правки.
Журнал версий — [references/changelog.md](references/changelog.md).
- [references/skeletons.md](references/skeletons.md) — **что именно класть** в
каждый незаполненный слот. Своей формой заглушку не выдумывай: `docs.py`
узнаёт только плейсхолдер `<!-- заполнить: … -->` из шаблонов.
- [references/changelog.md](references/changelog.md) — журнал версий канона.
## Три правила, из которых всё следует
@@ -53,9 +56,13 @@ python3 $ds version --dir <корень> # версия кано
нельзя**. `check`, отчитавшийся «канон соблюдён» на проекте, где из шести файлов
три лишние, хуже отсутствующего.
Машина проверяет пути, лишние файлы, битые ссылки, версию, нетронутые
плейсхолдеры, маркеры долга и две сверки с кодом. **Ты** судишь о том, чего она
не умеет:
Машина **дрейфом** считает: отсутствующий путь канона, файл вне канона, битую
ссылку, отставшую версию, capability без упоминания в обзоре, миграцию без правки
`database.md`. **Замечанием** — незаполненный плейсхолдер и слабое упоминание
capability: незаполненный канон это переходное состояние, а не отказ. Маркеры
долга просто считает числом.
**Ты** судишь о том, чего она не умеет:
- **смысловой дубль** — `docs/specs/recognition.md` описывает то же, что
capability `recognition`. Файлы разные, содержание одно;
@@ -113,8 +120,8 @@ capability), `openspec/config.yaml`.
Порядок важен — он минимизирует окно, в котором ссылки битые:
1. `docs/.pm.json` с `{"canon": <текущая версия>}` и путём миграций, если БД есть;
2. каталоги канона и скелет: незаполненное — **одной честной информативной
строкой**, а не «TBD» (см. canon.md, «Пустое называется пустым»);
2. каталоги канона и скелет **по [references/skeletons.md](references/skeletons.md)**:
незаполненное — одной честной информативной строкой, а не «TBD»;
3. переносы содержимого;
4. каталог задач — **вызови скилл `av-dev-pm:tasks`**, сценарий адаптации: он
владеет форматом задач, включая переименование транслитных слагов в
@@ -122,8 +129,15 @@ capability), `openspec/config.yaml`.
5. починка ссылок на перенесённое во всём репозитории — `docs/`, `openspec/`,
`CLAUDE.md`, `README.md`;
6. удаление оригиналов — **только тех, чьё содержимое найдено в новом доме**;
7. шаг `docs.py check` в гейт проекта;
8. `docs.py check` — до зелёного в механизируемой части.
7. **шаг `docs.py check` в гейт проекта.** Путь к скрипту — переменной с
умолчанием на канонический путь маркетплейса, чтобы переустановка плагина не
меняла `Taskfile`; шаг обязан **краснеть внятно**, если скрипт не найден, а не
пропускаться. Передай ему базу диффа (`--base`) той же переменной, что и
остальным шагам гейта: без неё сверка миграций со схемой не гоняется вовсе.
Пример строки покажи человеку — гейт принадлежит проекту, и правит его он;
8. `docs.py check` — до **отсутствия дрейфа**. Замечания (незаполненные
плейсхолдеры, слабое упоминание capability) остаются: незаполненный канон это
объявленное переходное состояние из шага 5, а не отказ.
### 5. Объяви переходное состояние
@@ -8,6 +8,13 @@
Плейсхолдер ставится только там, где ответ **обязан** быть и его не спросили.
Всё, чего в проекте пока просто нет, описывается словами, а не плейсхолдером.
**Шаблоны — единственное место, где правило канона копируется намеренно.**
`adr/README.md` и `review.md` уезжают в репозиторий проекта и обязаны там что-то
говорить; определение при этом остаётся в [canon.md](canon.md). Отсюда
обязанность: **правка такого правила в каноне тянет запись в
[changelog.md](changelog.md)** с указанием, какой файл проекта поднимает
`upgrade`. Без этого копия в проекте останется на старой версии молча.
## `docs/passport.md`
```markdown
+71 -24
View File
@@ -76,19 +76,25 @@ RETIRED = {
DEBT_MARKER = re.compile(r"<!--\s*канон:\s*(.+?)\s*-->")
PLACEHOLDER = re.compile(r"<!--\s*заполнить:\s*(.+?)\s*-->")
MD_LINK = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
MD_LINK = re.compile(r"\[[^\]]*\]\(\s*<?([^)>\s]+)>?(?:\s+[\"'(][^)]*)?\)")
FENCE = re.compile(r"^\s*(```|~~~)")
INLINE_CODE = re.compile(r"`[^`\n]*`")
def strip_code(text: str) -> str:
"""Выкинуть блоки кода: пути в примерах и шаблонах — не ссылки, и краснеть
на них значит краснеть на каждом образце документа."""
"""Выкинуть блоки кода и вставки в обратных кавычках.
Путь в примере или в шаблоне — не ссылка, и краснеть на нём значит краснеть
на каждом образце документа. Инлайн-код тоже: `[docs/backlog](docs/tasks/…)`
в тексте про подписи ссылок — иллюстрация, а не ссылка."""
out, inside = [], False
for line in text.splitlines():
if FENCE.match(line):
inside = not inside
continue
out.append("" if inside else line)
out.append("" if inside else INLINE_CODE.sub("", line))
return "\n".join(out)
@@ -226,7 +232,9 @@ def check_placeholders_and_debt(root: Path, rep: Report) -> None:
text = strip_code(path.read_text(encoding="utf-8", errors="replace"))
rel = path.relative_to(root)
for what in PLACEHOLDER.findall(text):
rep.error(f"{rel}: плейсхолдер шаблона не заполнен{what}")
# Замечание, а не дрейф: незаполненный канон — объявленное переходное
# состояние, и краснеть на нём значит требовать выдумать содержание.
rep.note(f"{rel}: плейсхолдер шаблона не заполнен — {what}")
for what in DEBT_MARKER.findall(text):
rep.debt(f"{rel}: {what}")
@@ -238,28 +246,60 @@ def check_capabilities(root: Path, rep: Report) -> None:
rep.skip("openspec/specs/ нет — сверка capability с архитектурой неприменима")
return
if not arch.exists():
rep.skip(
"docs/architecture.md нет — capability не сверены с обзором "
"(об отсутствии файла сказано отдельной строкой)"
)
return
text = arch.read_text(encoding="utf-8", errors="replace")
missing = [d.name for d in sorted(specs.iterdir()) if d.is_dir() and d.name not in text]
for name in missing:
rep.error(
f"capability {name} есть в openspec/specs/, но не упомянута в "
f"docs/architecture.md — обзор отстал от нормативных спек"
)
for d in sorted(specs.iterdir()):
if not d.is_dir():
continue
name = d.name
# Засчитываем только явное упоминание: ссылку на спеку или имя в обратных
# кавычках. Голая подстрока совпадает с именем пакета или CLI-команды и
# даёт ложное «упомянуто» — то есть проверку, проходящую не по той причине.
explicit = f"openspec/specs/{name}" in text or f"`{name}`" in text
loose = re.search(rf"\b{re.escape(name)}\b", text) is not None
if explicit:
continue
if loose:
rep.note(
f"capability {name}: в docs/architecture.md есть слово «{name}», но "
f"нет ни ссылки на openspec/specs/{name}, ни имени в обратных "
f"кавычках — проверь, это про capability или про пакет"
)
else:
rep.error(
f"capability {name} есть в openspec/specs/, но не упомянута в "
f"docs/architecture.md — обзор отстал от нормативных спек"
)
def changed_files(root: Path, base: str, rep: Report) -> list[str] | None:
try:
out = subprocess.run(
["git", "-C", str(root), "diff", "--name-only", f"{base}...HEAD"],
capture_output=True,
text=True,
check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
rep.skip(f"сверка миграций пропущена: git не отдал дифф ({exc})")
return None
return [line for line in out.stdout.splitlines() if line]
"""Объединение закоммиченного, рабочего дерева и untracked.
Гейт гоняют ДО коммита, поэтому `base...HEAD` не видит ровно ту правку, ради
которой проверка и заводилась: миграция уже лежит в дереве, но ещё не в
истории. Пропущенная правка выглядела бы как зелёный шаг."""
cmds = [
["diff", "--name-only", base],
["ls-files", "--others", "--exclude-standard"],
]
seen: list[str] = []
for cmd in cmds:
try:
out = subprocess.run(
["git", "-C", str(root), *cmd],
capture_output=True,
text=True,
check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
rep.skip(f"сверка миграций пропущена: git не отдал дифф ({exc})")
return None
seen.extend(line for line in out.stdout.splitlines() if line)
return sorted(set(seen))
def check_migrations(root: Path, cfg: dict, base: str | None, rep: Report) -> None:
@@ -292,17 +332,24 @@ def check_tasks(root: Path, rep: Report) -> None:
if not script.exists():
rep.skip(f"tasks.py не найден по пути {script} — согласованность задач не проверена")
return
# cwd=root обязателен: tasks.py отвергает --dir вне текущего каталога, и без
# этого его отказ окружения (код 3) схлопнулся бы в наш дрейф (код 1).
proc = subprocess.run(
[sys.executable, str(script), "check", "--dir", str(tasks)],
[sys.executable, str(script), "check", "--dir", "docs/tasks"],
capture_output=True,
text=True,
cwd=str(root),
)
if proc.returncode == 0:
return
if proc.returncode == 1:
rep.error("tasks.py check нашёл дрейф в docs/tasks/ — разбирать его командой tasks.py")
else:
rep.error(f"tasks.py check отказал с кодом {proc.returncode}: {proc.stderr.strip()}")
# Чужой код выхода не выдаём за свой: 3 это окружение, а не дрейф.
rep.skip(
f"tasks.py check не отработал (код {proc.returncode}): "
f"{(proc.stderr or proc.stdout).strip().splitlines()[0] if (proc.stderr or proc.stdout).strip() else 'без сообщения'}"
)
# --- Отчёт ------------------------------------------------------------------