docs(structure): govern .planning/ and register the ops runbook

- docs/STRUCTURE.md: add a .planning/ registry (vision/roadmap/history/breakdown
  roles), broaden the roadmap role, add enforcement items 8-10.
- tools/check_requirements.py: scan .planning/ too - every .planning/**.md must be
  registered, registered files must exist, requirement rows rejected there.
- .planning/: PROJECT.md, ROADMAP.md, MILESTONES.md, and milestones/v0.5/M3/
  {roadmap.md,task_breakdown.md}. M3 breakdown holds only the tasks; decisions live
  in the contracts.
- docs/DEPLOYMENT.md: add the ops runbook (registered as ops).
This commit is contained in:
2026-09-19 10:12:32 -07:00
parent 90e862cfb0
commit 6435199f08
8 changed files with 234 additions and 7 deletions
+19 -5
View File
@@ -16,14 +16,20 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DOCS = ROOT / "docs"
PLANNING = ROOT / ".planning"
STRUCTURE = DOCS / "STRUCTURE.md"
REQUIREMENTS = DOCS / "REQUIREMENTS.md"
MILESTONES = DOCS / "MILESTONES.md"
EXEMPT_PREFIXES = (("docs", "archive"), ("docs", "_templates"))
ROLES = "index|intent|meta|requirements|scope|capability|contract|practice"
REGISTRY_ROW = re.compile(rf"^\|\s*`(docs/[^`]+\.md)`\s*\|\s*({ROLES})\s*\|")
ROLES = (
"index|intent|meta|requirements|scope|capability|contract|practice|ops"
"|vision|roadmap|history|breakdown"
)
REGISTRY_ROW = re.compile(
rf"^\|\s*`((?:docs|\.planning)/[^`]+\.md)`\s*\|\s*({ROLES})\s*\|"
)
NAMESPACE_ROW = re.compile(r"^\|\s*`([A-Z]+)`\s*\|")
REQUIREMENT_ROW = re.compile(
r"^\|\s*([A-Z]+)-(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*$"
@@ -58,13 +64,19 @@ def main() -> int:
if not namespaces:
errors.append("STRUCTURE.md declares no ID namespaces")
# 1. every non-exempt doc is registered
planning_files = sorted(PLANNING.rglob("*.md")) if PLANNING.is_dir() else []
# 1. every non-exempt doc is registered; every planning file is registered
for path in sorted(DOCS.rglob("*.md")):
rel = path.relative_to(ROOT).as_posix()
if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES:
continue
if rel not in registered:
errors.append(f"unregistered doc: {rel}")
for path in planning_files:
rel = path.relative_to(ROOT).as_posix()
if rel not in registered:
errors.append(f"unregistered planning file: {rel}")
# 2. every registered doc exists
for rel in registered:
@@ -110,7 +122,7 @@ def main() -> int:
errors.append(f"{loc} {rid} invalid Test ref '{ref}' (use a path, an ID, or —)")
# 6. requirement rows appear only in REQUIREMENTS.md
for path in sorted(DOCS.rglob("*.md")):
for path in sorted(DOCS.rglob("*.md")) + planning_files:
if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES or path in {
REQUIREMENTS,
STRUCTURE,
@@ -148,8 +160,10 @@ def main() -> int:
print(f" - {error}")
return 1
planning_registered = sum(1 for rel in registered if rel.startswith(".planning/"))
print(
f"PASS check_requirements: {len(registered)} docs, "
f"PASS check_requirements: {len(registered) - planning_registered} docs, "
f"{planning_registered} planning, "
f"{len(namespaces)} namespaces, {len(ids)} requirements"
)
return 0