#!/usr/bin/env python3 """Regenerate .project/objectives/README.md from per-objective frontmatter. SSoT: each .project/objectives/p[012]-NN-*.md file carries its own status in YAML frontmatter. This script reads every such file, validates the fields, and emits a grouped dashboard. The dashboard is regenerated, not hand-edited. Usage: python3 tools/objectives-report.py # write README.md python3 tools/objectives-report.py --check # exit 1 if README.md is stale """ from __future__ import annotations import argparse import sys from dataclasses import dataclass from pathlib import Path REPO = Path(__file__).resolve().parent.parent OBJ_DIR = REPO / ".project" / "objectives" TEAM_LEADS_DIR = REPO / ".project" / "team-leads" OUT = OBJ_DIR / "README.md" VALID_STATUS = {"done", "partial", "stub", "missing", "oos"} VALID_PRIORITY = {"p0", "p1", "p2"} VALID_SCOPE = {"game1", "game2"} STATUS_ICON = { "done": "โœ…", "partial": "๐ŸŸก", "stub": "๐Ÿ”ด", "missing": "โŒ", "oos": "โšซ", } @dataclass(frozen=True) class Objective: id: str title: str priority: str status: str scope: str updated_at: str path: Path owner: str | None = None def parse_frontmatter(text: str, path: Path) -> dict[str, str]: """Parse a minimal YAML frontmatter block (flat key: value pairs). We deliberately avoid a PyYAML dependency โ€” frontmatter here is flat `key: value` plus occasional list fields we don't need to index. """ if not text.startswith("---\n"): raise ValueError(f"{path}: missing frontmatter opener '---'") end = text.find("\n---\n", 4) if end == -1: raise ValueError(f"{path}: missing frontmatter closer '---'") body = text[4:end] out: dict[str, str] = {} current_key: str | None = None for raw in body.splitlines(): line = raw.rstrip() if not line or line.lstrip().startswith("#"): continue # List-item continuation (we ignore list values for now) if line.startswith((" ", "\t", "-")): current_key = None continue if ":" not in line: raise ValueError(f"{path}: malformed frontmatter line: {raw!r}") key, _, value = line.partition(":") key = key.strip() value = value.strip() if value: out[key] = value current_key = key return out REQUIRED_FIELDS = ("id", "title", "priority", "status", "scope", "updated_at") def load_objectives() -> list[Objective]: out: list[Objective] = [] for path in sorted(OBJ_DIR.glob("p[012]-*.md")): if path.name == "README.md": continue text = path.read_text(encoding="utf-8") fm = parse_frontmatter(text, path) missing = [k for k in REQUIRED_FIELDS if k not in fm] if missing: raise ValueError(f"{path}: missing frontmatter keys: {missing}") if fm["priority"] not in VALID_PRIORITY: raise ValueError(f"{path}: invalid priority {fm['priority']!r}") if fm["status"] not in VALID_STATUS: raise ValueError(f"{path}: invalid status {fm['status']!r}") if fm["scope"] not in VALID_SCOPE: raise ValueError(f"{path}: invalid scope {fm['scope']!r}") owner = fm.get("owner") if owner is not None: lead_file = TEAM_LEADS_DIR / f"{owner}.md" if not lead_file.exists(): raise ValueError( f"{path}: owner {owner!r} has no identity file at " f"{lead_file.relative_to(REPO)}" ) out.append(Objective( id=fm["id"], title=fm["title"], priority=fm["priority"], status=fm["status"], scope=fm["scope"], updated_at=fm["updated_at"], path=path, owner=owner, )) out.sort(key=lambda o: (o.priority, o.id)) return out def render(objectives: list[Objective]) -> str: by_priority: dict[str, list[Objective]] = {"p0": [], "p1": [], "p2": []} for o in objectives: by_priority[o.priority].append(o) counts = {s: sum(1 for o in objectives if o.status == s) for s in VALID_STATUS} total = len(objectives) lines: list[str] = [] lines.append("# Objectives โ€” Dashboard") lines.append("") lines.append( "> **Generated by `tools/objectives-report.py` โ€” do not hand-edit.** " "Source of truth is per-file YAML frontmatter in this directory." ) lines.append("") lines.append("## Legend") lines.append("") lines.append("โœ… done ยท ๐ŸŸก partial ยท ๐Ÿ”ด stub ยท โŒ missing ยท โšซ out-of-scope (Game 2)") lines.append("") lines.append("## Totals") lines.append("") lines.append("| Status | Count |") lines.append("|---|---|") for status in ("done", "partial", "stub", "missing", "oos"): lines.append(f"| {STATUS_ICON[status]} {status} | {counts[status]} |") lines.append(f"| **total** | **{total}** |") lines.append("") priority_heading = { "p0": "P0 โ€” Blockers for \"completely playable\"", "p1": "P1 โ€” Ship-readiness", "p2": "P2 โ€” Polish", } def render_row(o: Objective) -> str: link = f"[{o.id}]({o.path.name})" icon = STATUS_ICON[o.status] owner_cell = ( f"[{o.owner}](../team-leads/{o.owner}.md)" if o.owner else "โ€”" ) return ( f"| {link} | {icon} {o.status} | {o.title} " f"| {owner_cell} | {o.updated_at} |" ) # Priority sections render only in-scope (non-oos) objectives. OOS items # are collected and rendered in a separate trailing section so they don't # compete with active work for attention. for prio in ("p0", "p1", "p2"): group = [o for o in by_priority[prio] if o.status != "oos"] if not group: continue lines.append(f"## {priority_heading[prio]}") lines.append("") lines.append("| ID | Status | Title | Owner | Updated |") lines.append("|---|---|---|---|---|") for o in group: lines.append(render_row(o)) lines.append("") oos_items = [o for o in objectives if o.status == "oos"] if oos_items: lines.append("## Out of Scope (Game 2)") lines.append("") lines.append( "> These objectives are explicitly future-scope for **Game 2 " "(Age of Kzzykt)**. They are **not** part of the Game 1 Early " "Access release and are listed only for reference. Do not treat " "them as priorities." ) lines.append("") lines.append("| ID | Status | Title | Owner | Updated |") lines.append("|---|---|---|---|---|") for o in oos_items: lines.append(render_row(o)) lines.append("") return "\n".join(lines) + "\n" def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument( "--check", action="store_true", help="Exit 1 if README.md is stale instead of writing", ) args = ap.parse_args() try: objectives = load_objectives() except ValueError as e: print(f"error: {e}", file=sys.stderr) return 2 rendered = render(objectives) if args.check: existing = OUT.read_text(encoding="utf-8") if OUT.exists() else "" if existing != rendered: print(f"stale: {OUT.relative_to(REPO)} โ€” run tools/objectives-report.py", file=sys.stderr) return 1 return 0 OUT.write_text(rendered, encoding="utf-8") print(f"wrote {OUT.relative_to(REPO)} ({len(objectives)} objectives)") return 0 if __name__ == "__main__": raise SystemExit(main())