#!/usr/bin/env python3 """Regenerate .project/objectives/README.md + public objectives.json. 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 (1) a grouped dashboard for humans and (2) a structured JSON export for the in-guide Progress Report page. Usage: python3 tools/objectives-report.py # write README.md + objectives.json python3 tools/objectives-report.py --check # exit 1 if either file is stale """ from __future__ import annotations import argparse import json import sys from dataclasses import dataclass from datetime import datetime, timezone 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" JSON_OUT = REPO / "public" / "games" / "age-of-dwarves" / "data" / "objectives.json" VALID_STATUS = {"done", "partial", "stub", "missing", "oos"} VALID_PRIORITY = {"p0", "p1", "p2", "p3"} VALID_SCOPE = {"game1", "game2", "game3"} 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 summary: str 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 extract_summary(text: str) -> str: """Extract the body paragraph(s) under the first `## Summary` heading. Returns the text between `## Summary` and the next `## ` heading, trimmed. Empty string if no Summary section exists โ€” every objective should have one but we don't fail if it's missing (prose-level editorial, not a gate). """ marker = "\n## Summary\n" start = text.find(marker) if start == -1: return "" body_start = start + len(marker) # Next top-level section heading terminates the summary block. next_h2 = text.find("\n## ", body_start) block = text[body_start:] if next_h2 == -1 else text[body_start:next_h2] return block.strip() 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, summary=extract_summary(text), 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": [], "p3": []} 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 / Game 3)") lines.append("") lines.append("## Totals") lines.append("") # --- by-priority breakdown --- prio_labels = {"p0": "P0", "p1": "P1", "p2": "P2", "p3": "P3 (oos)"} prio_table: list[str] = [ "| Priority | โœ… | ๐ŸŸก | ๐Ÿ”ด | โŒ | โšซ | Total |", "|---|---|---|---|---|---|---|", ] for prio in ("p0", "p1", "p2", "p3"): grp = by_priority[prio] row = {s: sum(1 for o in grp if o.status == s) for s in VALID_STATUS} prio_table.append( f"| **{prio_labels[prio]}** " f"| {row['done']} | {row['partial']} | {row['stub']} " f"| {row['missing']} | {row['oos']} | {len(grp)} |" ) prio_table.append( f"| **total** | **{counts['done']}** | **{counts['partial']}** " f"| **{counts['stub']}** | **{counts['missing']}** " f"| **{counts['oos']}** | **{total}** |" ) # --- left-to-do by team lead (partial + stub + missing only) --- by_lead: dict[str, int] = {} for o in objectives: if o.status in ("partial", "stub", "missing") and o.owner: by_lead[o.owner] = by_lead.get(o.owner, 0) + 1 lead_table: list[str] = ["| Team Lead | Remaining |", "|---|---|"] for lead, cnt in sorted(by_lead.items(), key=lambda x: -x[1]): lead_table.append(f"| [{lead}](../team-leads/{lead}.md) | {cnt} |") if not by_lead: lead_table.append("| โ€” | 0 |") # side-by-side via HTML (works in Forgejo/GitHub markdown) lines.append("
") lines.append("") lines.append("**By Priority**") lines.append("") lines.extend(prio_table) lines.append("") lines.append("") lines.append("") lines.append("**Left To Do by Lead**") lines.append("") lines.extend(lead_table) lines.append("") lines.append("
") 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 / Game 3)") lines.append("") lines.append( "> These objectives are explicitly future-scope. " "**Game 2 (Age of Kzzykt)** items introduce leylines, the Green school, " "and spacefaring. **Game 3 (Age of Elves)** items cover the full " "five-school magic system, Archons, and Arcane Ascension. " "None are part of the Game 1 Early Access release." ) 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 render_json(objectives: list[Objective]) -> str: """Serialise objectives into the structured form consumed by the guide.""" counts = {s: sum(1 for o in objectives if o.status == s) for s in VALID_STATUS} counts["total"] = len(objectives) payload = { "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "totals": counts, "objectives": [ { "id": o.id, "title": o.title, "priority": o.priority, "status": o.status, "scope": o.scope, "owner": o.owner, "updated_at": o.updated_at, "summary": o.summary, } for o in objectives ], } return json.dumps(payload, indent=2, ensure_ascii=False) + "\n" def _json_body(rendered: str) -> object: """Strip the volatile `generated_at` field before diffing for --check. `generated_at` changes on every run; comparing it would make --check always fail. We diff the payload minus that one timestamp. """ data = json.loads(rendered) data.pop("generated_at", None) return data def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument( "--check", action="store_true", help="Exit 1 if README.md or objectives.json 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) rendered_json = render_json(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 existing_json = JSON_OUT.read_text(encoding="utf-8") if JSON_OUT.exists() else "" try: existing_body = _json_body(existing_json) if existing_json else None except json.JSONDecodeError: existing_body = None if existing_body != _json_body(rendered_json): print(f"stale: {JSON_OUT.relative_to(REPO)} โ€” run tools/objectives-report.py", file=sys.stderr) return 1 return 0 OUT.write_text(rendered, encoding="utf-8") JSON_OUT.parent.mkdir(parents=True, exist_ok=True) JSON_OUT.write_text(rendered_json, encoding="utf-8") print(f"wrote {OUT.relative_to(REPO)} ({len(objectives)} objectives)") print(f"wrote {JSON_OUT.relative_to(REPO)}") return 0 if __name__ == "__main__": raise SystemExit(main())