From 40c3a7fc4b8229556d0e2ae617b58ab5d8b46107 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 17:53:44 -0500 Subject: [PATCH] docs(plan): content & canon build tool implementation plan 8 TDD tasks: package scaffold + parser, Entry model, validation gate (rungs+resolve), emit routing + secrecy lint, CLI --check, Duncarrow promotion (acceptance test), client ContentDB canon/topics dirs, CI gate. Branches off dev per s18; runtime consumption + Fenn migration + export packaging explicitly deferred. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ --- .../2026-07-11-content-canon-build-tool.md | 1440 +++++++++++++++++ 1 file changed, 1440 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-content-canon-build-tool.md diff --git a/docs/superpowers/plans/2026-07-11-content-canon-build-tool.md b/docs/superpowers/plans/2026-07-11-content-canon-build-tool.md new file mode 100644 index 0000000..3d9a906 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-content-canon-build-tool.md @@ -0,0 +1,1440 @@ +# Content & Canon Build Tool Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Python compiler that turns the authored Markdown bible (`content/lore/*.md`, prose + fenced `yaml` blocks) into validated, role-split runtime JSON (`content/world` client tree + `content/server` server-only tree), and promote Duncarrow as its acceptance test. + +**Architecture:** A standalone `tools/content_build/` package with a four-stage pipeline — parse (Markdown → yaml blocks) → resolve/validate (a hard gate that aborts on any schema violation) → emit (route each field per the schema's role split) → write. Run manually (`python -m content_build`) or as a CI gate (`--check`, fails on stale-or-invalid). The build carries **no disposition numbers** — it validates rung *names* only; the client owns the band integers and the rung→midpoint mapping. + +**Tech Stack:** Python 3.12, PyYAML (block parsing), pytest (TDD). No new runtime deps for the `api` proxy — the tool is dev/CI only. GDScript for the one client-loader change. + +## Global Constraints + +- **Locked schema — do not re-litigate.** The contract is fixed by `docs/superpowers/specs/2026-07-11-content-canon-schema-design.md`; this plan realizes `docs/superpowers/specs/2026-07-11-content-canon-build-tool-design.md`. +- **Charter §18 git flow:** this is non-doc code → all work on a branch off `dev` (`feature/content-build-tool`). Never commit to `master`. Doc/plan commits may land on `dev`. The human owns `dev → master`. +- **The build tool must not be importable by / bundled into the `api` proxy** (it ships to fly.io). It lives under `tools/`, never under `api/`. +- **Bands live in code, not the bible; the build never hardcodes a band integer.** Rung *names/order* come from the bible's `rule.disposition-ladder` `rungs:` field. +- **Only `status: canon` entries emit into shipping artifacts.** `candidate` entries still parse + validate (authors get errors early) but are not emitted. +- **Built JSON is committed.** Deterministic serialization (`json.dumps(obj, indent=2, sort_keys=True) + "\n"`) so `--check` diffs are stable. +- **The build never clears `content/world/npcs/`** — it is shared with legacy hand-authored NPCs (`fenn.json`, `cadwyn_vell.json`, `brannoc_thane.json`). The build overwrites only files it generates. +- **All commands run from the repo root**, using the repo venv and `PYTHONPATH=tools`: + - Tests: `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests -q` + - Build: `PYTHONPATH=tools .venv/bin/python -m content_build` + - Check: `PYTHONPATH=tools .venv/bin/python -m content_build --check` + +--- + +## File Structure + +``` +tools/ + requirements-dev.txt # PyYAML + pytest pins (dev/CI only) + content_build/ + __init__.py + __main__.py # CLI: build / --check, orchestration, exit codes + errors.py # BuildError(message, source, entry_id) + parse.py # parse_bible(path) -> list[RawEntry] + model.py # Entry / KnowsLink dataclasses + entry_from_raw + predicates + rungs.py # ladder_rungs(), legal_gates() — the rung vocabulary + resolve.py # resolve(entries) -> World (the validation gate) + emit.py # build_trees / check_secrecy / write_trees / dump_json + tests/ + __init__.py + fixtures/valid/world.md # a minimal valid bible for emit + cli tests + test_parse.py + test_model.py + test_resolve.py # one test per gate rule + test_emit.py + test_cli.py + test_duncarrow.py # real end-to-end round-trip (after promotion) +content/ + lore/duncarrow.md # promoted from candidate-town.md (Task 6) + lore/canon-roadmap.md # authored-vs-pending index (Task 6) + world/{canon,topics}/*.json # BUILT (Task 6 output, committed) + world/npcs/*.json # BUILT skeletons alongside legacy files + server/{npcs,topics}/*.json # BUILT server-only (Task 6 output, committed) +client/scripts/content/content_db.gd # +canon/ +topics/ dirs (Task 7) +client/tests/unit/test_content_db.gd # +loader tests (Task 7) +.github/workflows/content-build.yml # CI gate (Task 8) +``` + +--- + +### Task 1: Package scaffold, errors, and the Markdown parser + +**Files:** +- Create: `tools/requirements-dev.txt` +- Create: `tools/content_build/__init__.py` (empty) +- Create: `tools/content_build/errors.py` +- Create: `tools/content_build/parse.py` +- Create: `tools/content_build/tests/__init__.py` (empty) +- Test: `tools/content_build/tests/test_parse.py` + +**Interfaces:** +- Produces: `BuildError(message: str, source: str = "", entry_id: str = "")` with `.message/.source/.entry_id` attrs; `RawEntry` dataclass `{data: dict, source: str}`; `parse_bible(path: Path) -> list[RawEntry]`. + +- [ ] **Step 1: Create the branch** + +```bash +git checkout dev && git pull --ff-only 2>/dev/null; git checkout -b feature/content-build-tool +``` + +- [ ] **Step 2: Write the dev requirements and empty package files** + +`tools/requirements-dev.txt`: +``` +# Build-tool dev/CI deps. NOT installed into the api proxy runtime. +PyYAML==6.0.3 +pytest==8.3.2 +``` + +Create empty `tools/content_build/__init__.py` and `tools/content_build/tests/__init__.py`: +```bash +mkdir -p tools/content_build/tests/fixtures/valid +: > tools/content_build/__init__.py +: > tools/content_build/tests/__init__.py +``` + +- [ ] **Step 3: Write `errors.py`** + +`tools/content_build/errors.py`: +```python +"""The single failure type. Every validation/parse problem raises BuildError so +the CLI can print a uniform 'BUILD FAILED: [] ()'.""" + + +class BuildError(Exception): + def __init__(self, message: str, source: str = "", entry_id: str = ""): + self.message = message + self.source = source + self.entry_id = entry_id + loc = f" [{entry_id}]" if entry_id else "" + src = f" ({source})" if source else "" + super().__init__(f"{message}{loc}{src}") +``` + +- [ ] **Step 4: Write the failing parser test** + +`tools/content_build/tests/test_parse.py`: +```python +from pathlib import Path + +import pytest + +from content_build.errors import BuildError +from content_build.parse import parse_bible + +BIBLE = """# A bible + +Some prose that must be ignored. + +```yaml +id: town.a +type: town +status: canon +secrecy: 0 +related: [] +body: > + Town A. +``` + +More prose. + +```yaml +id: rumor.x +type: rumor +status: canon +secrecy: 1 +related: [town.a] +body: > + A rumor. +``` +""" + + +def _write(tmp_path: Path, text: str) -> Path: + p = tmp_path / "bible.md" + p.write_text(text) + return p + + +def test_extracts_yaml_blocks_and_ignores_prose(tmp_path): + entries = parse_bible(_write(tmp_path, BIBLE)) + assert [e.data["id"] for e in entries] == ["town.a", "rumor.x"] + + +def test_source_carries_filename_and_line(tmp_path): + entries = parse_bible(_write(tmp_path, BIBLE)) + assert entries[0].source.startswith("bible.md:") + + +def test_unterminated_block_raises(tmp_path): + bad = "```yaml\nid: town.a\n" # no closing fence + with pytest.raises(BuildError): + parse_bible(_write(tmp_path, bad)) + + +def test_non_mapping_block_raises(tmp_path): + bad = "```yaml\n- just\n- a\n- list\n```\n" + with pytest.raises(BuildError): + parse_bible(_write(tmp_path, bad)) +``` + +- [ ] **Step 5: Run the test to verify it fails** + +Run: `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests/test_parse.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'content_build.parse'`. + +- [ ] **Step 6: Write `parse.py`** + +`tools/content_build/parse.py`: +```python +"""Markdown bible -> raw yaml-block entries. Knows only fences + YAML; nothing +about the schema's meaning. A block is opened by a line that is exactly ```yaml +(after strip) and closed by a line that is exactly ```.""" + +from dataclasses import dataclass +from pathlib import Path + +import yaml + +from .errors import BuildError + + +@dataclass +class RawEntry: + data: dict + source: str # "duncarrow.md:53" + + +def parse_bible(path: Path) -> list[RawEntry]: + lines = path.read_text().splitlines() + entries: list[RawEntry] = [] + i, n = 0, len(lines) + while i < n: + if lines[i].strip() == "```yaml": + fence_line = i + 1 # 1-based line of the opening fence + i += 1 + block: list[str] = [] + while i < n and lines[i].strip() != "```": + block.append(lines[i]) + i += 1 + if i >= n: + raise BuildError("unterminated ```yaml block", + source=f"{path.name}:{fence_line}") + data = yaml.safe_load("\n".join(block)) + if not isinstance(data, dict): + raise BuildError("yaml block is not a mapping", + source=f"{path.name}:{fence_line}") + entries.append(RawEntry(data=data, source=f"{path.name}:{fence_line}")) + i += 1 + return entries +``` + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests/test_parse.py -q` +Expected: PASS (4 passed). + +- [ ] **Step 8: Commit** + +```bash +git add tools/requirements-dev.txt tools/content_build/__init__.py tools/content_build/errors.py tools/content_build/parse.py tools/content_build/tests/__init__.py tools/content_build/tests/test_parse.py +git commit -m "feat(content-build): scaffold package + Markdown yaml-block parser" +``` + +--- + +### Task 2: The entry model + +**Files:** +- Create: `tools/content_build/model.py` +- Test: `tools/content_build/tests/test_model.py` + +**Interfaces:** +- Consumes: `RawEntry` (Task 1). +- Produces: + - Constants `CANON_TYPES`, `KNOWLEDGE_TYPES`, `LEGAL_NAMESPACES`, `STATUSES` (all `set[str]`). + - `KnowsLink` dataclass `{fact_id: str, gate: str}`. + - `Entry` dataclass with fields `id, type, status, secrecy, related, body, source, start_disposition, knows (list[KnowsLink]), disposition_notes, rungs` and read-only properties `namespace, slug, is_npc_layer, is_knowledge, is_canon_entity`. + - `entry_from_raw(raw: RawEntry) -> Entry`. + +- [ ] **Step 1: Write the failing model test** + +`tools/content_build/tests/test_model.py`: +```python +import pytest + +from content_build.errors import BuildError +from content_build.model import Entry, entry_from_raw +from content_build.parse import RawEntry + + +def _raw(**over) -> RawEntry: + data = {"id": "town.a", "type": "town", "status": "canon", + "secrecy": 0, "related": []} + data.update(over) + return RawEntry(data=data, source="t:1") + + +def test_namespace_and_slug(): + e = entry_from_raw(_raw(id="npc.mera-fenn", type="person", + start_disposition="cold", + knows=[{"fact": "rumor.x", "gate": "warm"}])) + assert e.namespace == "npc" + assert e.slug == "mera-fenn" + + +def test_category_predicates(): + npc = entry_from_raw(_raw(id="npc.t", type="person", + start_disposition="cold", + knows=[{"fact": "rumor.x", "gate": "warm"}])) + person = entry_from_raw(_raw(id="person.p", type="person")) + rumor = entry_from_raw(_raw(id="rumor.x", type="rumor", secrecy=1)) + assert npc.is_npc_layer and not npc.is_canon_entity + assert person.is_canon_entity and not person.is_npc_layer + assert rumor.is_knowledge and not rumor.is_canon_entity + + +def test_knows_parsed_into_links(): + e = entry_from_raw(_raw(id="npc.t", type="person", start_disposition="cold", + knows=[{"fact": "rumor.x", "gate": "neutral"}])) + assert e.knows[0].fact_id == "rumor.x" + assert e.knows[0].gate == "neutral" + + +def test_missing_required_field_raises(): + bad = RawEntry(data={"id": "town.a", "type": "town"}, source="t:1") + with pytest.raises(BuildError): + entry_from_raw(bad) + + +def test_knows_missing_gate_raises(): + with pytest.raises(BuildError): + entry_from_raw(_raw(id="npc.t", type="person", start_disposition="cold", + knows=[{"fact": "rumor.x"}])) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests/test_model.py -q` +Expected: FAIL — `No module named 'content_build.model'`. + +- [ ] **Step 3: Write `model.py`** + +`tools/content_build/model.py`: +```python +"""Typed entries + the envelope/category rules. Pure data; no I/O. The npc +interactive layer vs the person world-record is distinguished by id NAMESPACE +(prefix), not by `type` (both are type 'person').""" + +from dataclasses import dataclass, field + +from .errors import BuildError +from .parse import RawEntry + +CANON_TYPES = {"town", "place", "region", "faction", "person", "rule"} +KNOWLEDGE_TYPES = {"rumor", "fact", "secret"} +LEGAL_NAMESPACES = {"town", "place", "region", "faction", "person", "rule", + "rumor", "fact", "secret", "npc"} +STATUSES = {"candidate", "canon"} + + +@dataclass +class KnowsLink: + fact_id: str + gate: str + + +@dataclass +class Entry: + id: str + type: str + status: str + secrecy: int + related: list + body: str | None + source: str + start_disposition: str | None = None + knows: list = field(default_factory=list) # list[KnowsLink] + disposition_notes: str | None = None + rungs: list | None = None + + @property + def namespace(self) -> str: + return self.id.split(".", 1)[0] + + @property + def slug(self) -> str: + parts = self.id.split(".", 1) + return parts[1] if len(parts) == 2 else parts[0] + + @property + def is_npc_layer(self) -> bool: + return self.namespace == "npc" + + @property + def is_knowledge(self) -> bool: + return self.type in KNOWLEDGE_TYPES + + @property + def is_canon_entity(self) -> bool: + return (not self.is_npc_layer) and self.type in CANON_TYPES + + +def entry_from_raw(raw: RawEntry) -> Entry: + d = raw.data + for key in ("id", "type", "status", "secrecy", "related"): + if key not in d: + raise BuildError(f"missing required field '{key}'", + source=raw.source, entry_id=d.get("id", "")) + knows: list[KnowsLink] = [] + for k in d.get("knows", []): + if "fact" not in k or "gate" not in k: + raise BuildError("knows entry needs 'fact' and 'gate'", + source=raw.source, entry_id=d.get("id", "")) + knows.append(KnowsLink(fact_id=k["fact"], gate=k["gate"])) + return Entry( + id=d["id"], type=d["type"], status=d["status"], secrecy=d["secrecy"], + related=list(d["related"]), body=d.get("body"), source=raw.source, + start_disposition=d.get("start_disposition"), knows=knows, + disposition_notes=d.get("disposition_notes"), rungs=d.get("rungs"), + ) +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests/test_model.py -q` +Expected: PASS (5 passed). + +- [ ] **Step 5: Commit** + +```bash +git add tools/content_build/model.py tools/content_build/tests/test_model.py +git commit -m "feat(content-build): typed Entry model + category predicates" +``` + +--- + +### Task 3: The validation gate (`rungs.py` + `resolve.py`) + +**Files:** +- Create: `tools/content_build/rungs.py` +- Create: `tools/content_build/resolve.py` +- Test: `tools/content_build/tests/test_resolve.py` + +**Interfaces:** +- Consumes: `Entry`, `KnowsLink`, category constants (Task 2). +- Produces: + - `rungs.py`: `LADDER_ID = "rule.disposition-ladder"`, `NEVER = "never"`, `ladder_rungs(entries) -> list[str]`, `legal_gates(entries) -> set[str]`. + - `resolve.py`: `World` dataclass `{entries: list[Entry], by_id: dict[str, Entry]}`; `resolve(entries: list[Entry]) -> World` (raises `BuildError` on the first violation). + +- [ ] **Step 1: Write the failing resolve tests (one per rule)** + +`tools/content_build/tests/test_resolve.py`: +```python +import pytest + +from content_build.errors import BuildError +from content_build.model import Entry, KnowsLink +from content_build.resolve import resolve + + +def ladder(): + return Entry(id="rule.disposition-ladder", type="rule", status="canon", + secrecy=0, related=[], body="x", source="t:1", + rungs=["hostile", "cold", "neutral", "warm", "trusted"]) + + +def town(): + return Entry(id="town.a", type="town", status="canon", secrecy=0, + related=[], body="A town.", source="t:2") + + +def rumor(): + return Entry(id="rumor.x", type="rumor", status="canon", secrecy=1, + related=["town.a"], body="A rumor.", source="t:3") + + +def npc(**over): + base = dict(id="npc.t", type="person", status="canon", secrecy=0, + related=["town.a"], body="Persona.", source="t:4", + start_disposition="cold", + knows=[KnowsLink("rumor.x", "neutral")]) + base.update(over) + return Entry(**base) + + +def test_valid_world_resolves(): + world = resolve([ladder(), town(), rumor(), npc()]) + assert world.by_id["npc.t"].id == "npc.t" + + +def test_duplicate_id(): + with pytest.raises(BuildError): + resolve([ladder(), town(), town()]) + + +def test_illegal_namespace(): + bad = Entry(id="shrine.s", type="place", status="canon", secrecy=0, + related=[], body="x", source="t:9") + with pytest.raises(BuildError): + resolve([ladder(), bad]) + + +def test_id_type_mismatch(): + bad = Entry(id="place.s", type="town", status="canon", secrecy=0, + related=[], body="x", source="t:9") + with pytest.raises(BuildError): + resolve([ladder(), bad]) + + +def test_npc_type_must_be_person(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), npc(type="town")]) + + +def test_dangling_related(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), + npc(related=["town.nowhere"])]) + + +def test_person_record_with_knows_rejected(): + rec = Entry(id="person.p", type="person", status="canon", secrecy=0, + related=[], body="x", source="t:9", + knows=[KnowsLink("rumor.x", "warm")]) + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), rec]) + + +def test_npc_missing_start_disposition(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), npc(start_disposition=None)]) + + +def test_knows_target_must_be_knowledge_entry(): + # points at the town (a canon entity), not a knowledge entry + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), + npc(knows=[KnowsLink("town.a", "cold")])]) + + +def test_illegal_gate(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), + npc(knows=[KnowsLink("rumor.x", "besties")])]) + + +def test_never_is_a_legal_gate(): + world = resolve([ladder(), town(), rumor(), + npc(knows=[KnowsLink("rumor.x", "never")])]) + assert world.by_id["npc.t"].knows[0].gate == "never" + + +def test_missing_ladder(): + with pytest.raises(BuildError): + resolve([town()]) + + +def test_illegal_start_disposition(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), npc(start_disposition="besties")]) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests/test_resolve.py -q` +Expected: FAIL — `No module named 'content_build.resolve'`. + +- [ ] **Step 3: Write `rungs.py`** + +`tools/content_build/rungs.py`: +```python +"""The rung vocabulary, sourced from the bible's disposition-ladder entry. The +bible owns rung names/order; band NUMBERS live in client code and never here.""" + +from .errors import BuildError +from .model import Entry + +LADDER_ID = "rule.disposition-ladder" +NEVER = "never" + + +def ladder_rungs(entries: list[Entry]) -> list[str]: + ladders = [e for e in entries if e.id == LADDER_ID] + if len(ladders) != 1: + raise BuildError( + f"exactly one '{LADDER_ID}' entry required, found {len(ladders)}") + if not ladders[0].rungs: + raise BuildError("disposition-ladder needs a non-empty 'rungs:' list", + source=ladders[0].source, entry_id=LADDER_ID) + return list(ladders[0].rungs) + + +def legal_gates(entries: list[Entry]) -> set[str]: + return set(ladder_rungs(entries)) | {NEVER} +``` + +- [ ] **Step 4: Write `resolve.py`** + +`tools/content_build/resolve.py`: +```python +"""The validation gate. Pure function over parsed entries: builds the id map and +enforces every schema rule, raising BuildError on the first violation. Emits +nothing. Validates ALL entries (candidate + canon) so authors get errors early; +emit (Task 4) filters to canon.""" + +from dataclasses import dataclass + +from .errors import BuildError +from .model import Entry, KNOWLEDGE_TYPES, LEGAL_NAMESPACES, STATUSES +from .rungs import ladder_rungs, legal_gates + + +@dataclass +class World: + entries: list # list[Entry] + by_id: dict # id -> Entry + + +def resolve(entries: list[Entry]) -> World: + by_id: dict[str, Entry] = {} + for e in entries: + if e.id in by_id: + raise BuildError("duplicate id", source=e.source, entry_id=e.id) + by_id[e.id] = e + + for e in entries: + if e.status not in STATUSES: + raise BuildError(f"illegal status '{e.status}'", + source=e.source, entry_id=e.id) + if e.namespace not in LEGAL_NAMESPACES: + raise BuildError(f"illegal id namespace '{e.namespace}'", + source=e.source, entry_id=e.id) + if e.namespace == "npc": + if e.type != "person": + raise BuildError("npc.* entries must have type 'person'", + source=e.source, entry_id=e.id) + elif e.type != e.namespace: + raise BuildError( + f"id namespace '{e.namespace}' must equal type '{e.type}'", + source=e.source, entry_id=e.id) + + for e in entries: + for rid in e.related: + if rid not in by_id: + raise BuildError(f"related id '{rid}' does not resolve", + source=e.source, entry_id=e.id) + + for e in entries: + if e.is_npc_layer: + if e.start_disposition is None: + raise BuildError("npc.* requires start_disposition", + source=e.source, entry_id=e.id) + if not e.knows: + raise BuildError("npc.* requires a non-empty knows list", + source=e.source, entry_id=e.id) + else: + if e.knows: + raise BuildError("non-npc entry must not carry a knows list", + source=e.source, entry_id=e.id) + if e.start_disposition is not None: + raise BuildError("non-npc entry must not carry start_disposition", + source=e.source, entry_id=e.id) + + gates = legal_gates(entries) # enforces the single ladder + rungs + rungs = set(ladder_rungs(entries)) + for e in entries: + if not e.is_npc_layer: + continue + if e.start_disposition not in rungs: + raise BuildError( + f"start_disposition '{e.start_disposition}' is not a legal rung", + source=e.source, entry_id=e.id) + for link in e.knows: + if link.fact_id not in by_id: + raise BuildError(f"knows fact '{link.fact_id}' does not resolve", + source=e.source, entry_id=e.id) + target = by_id[link.fact_id] + if target.type not in KNOWLEDGE_TYPES: + raise BuildError( + f"knows target '{link.fact_id}' is not a knowledge entry " + f"(is '{target.type}')", source=e.source, entry_id=e.id) + if link.gate not in gates: + raise BuildError(f"illegal gate '{link.gate}'", + source=e.source, entry_id=e.id) + + return World(entries=entries, by_id=by_id) +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests/test_resolve.py -q` +Expected: PASS (13 passed). + +- [ ] **Step 6: Commit** + +```bash +git add tools/content_build/rungs.py tools/content_build/resolve.py tools/content_build/tests/test_resolve.py +git commit -m "feat(content-build): validation gate (rungs + resolve)" +``` + +**Note (accepted this cycle):** cross-status `related` integrity (a `canon` entry referencing a `candidate` entry, which would emit a dangling reference) is **not** checked — Duncarrow promotes atomically (all entries flip together). Revisit if a bible ever ships mixed-status. + +--- + +### Task 4: Emit — routing, secrecy safety-check, write + +**Files:** +- Create: `tools/content_build/emit.py` +- Create: `tools/content_build/tests/fixtures/valid/world.md` +- Test: `tools/content_build/tests/test_emit.py` + +**Interfaces:** +- Consumes: `World` (Task 3), `Entry`, `LADDER_ID` (Task 3). +- Produces: + - `dump_json(obj) -> str` — `json.dumps(obj, indent=2, sort_keys=True) + "\n"`. + - `build_trees(world: World) -> tuple[dict, dict]` — `(client_files, server_files)`, each `{relpath: json_obj}` over **canon-status** entries only. + - `check_secrecy(client_files: dict, world: World) -> None` — raises `BuildError` if any client record carrying a `body` originates from a `secrecy >= 3` entry. + - `write_trees(client_files, server_files, world_dir: Path, server_dir: Path) -> None`. + +- [ ] **Step 1: Write the valid fixture bible** + +`tools/content_build/tests/fixtures/valid/world.md`: +```markdown +# Test bible + +```yaml +id: rule.disposition-ladder +type: rule +status: canon +secrecy: 0 +related: [] +rungs: [hostile, cold, neutral, warm, trusted] +body: > + The five-rung ladder. +``` + +```yaml +id: town.testburg +type: town +status: canon +secrecy: 0 +related: [] +body: > + A test town. +``` + +```yaml +id: rumor.something +type: rumor +status: canon +secrecy: 1 +related: [town.testburg] +body: > + A rumor body. +``` + +```yaml +id: secret.big-twist +type: secret +status: canon +secrecy: 4 +related: [town.testburg] +body: > + The twist body. Server-only. +``` + +```yaml +id: npc.tess +type: person +status: canon +secrecy: 0 +start_disposition: cold +related: [town.testburg] +body: > + Tess persona. +knows: + - {fact: rumor.something, gate: neutral} + - {fact: secret.big-twist, gate: never} +disposition_notes: > + Author notes. +``` +``` + +- [ ] **Step 2: Write the failing emit test** + +`tools/content_build/tests/test_emit.py`: +```python +from pathlib import Path + +import pytest + +from content_build.emit import build_trees, check_secrecy, write_trees, dump_json +from content_build.model import entry_from_raw +from content_build.parse import parse_bible +from content_build.resolve import resolve +from content_build.errors import BuildError + +FIXTURE = Path(__file__).parent / "fixtures" / "valid" / "world.md" + + +def _world(): + raws = parse_bible(FIXTURE) + return resolve([entry_from_raw(r) for r in raws]) + + +def test_npc_skeleton_has_no_persona_client_side(): + client, _ = build_trees(_world()) + npc = client["npcs/tess.json"] + assert "persona" not in npc and "body" not in npc + assert npc["start_disposition"] == "cold" + assert {"fact_id": "secret.big-twist", "gate": "never"} in npc["knows"] + + +def test_persona_goes_server_side(): + _, server = build_trees(_world()) + assert server["npcs/tess.json"]["persona"].startswith("Tess") + + +def test_secret_body_only_server_side(): + client, server = build_trees(_world()) + assert "topics/big-twist.json" in server + assert server["topics/big-twist.json"]["body"].startswith("The twist") + # client topic skeleton carries no body + assert "body" not in client["topics/big-twist.json"] + + +def test_canon_entity_body_ships_client_side(): + client, _ = build_trees(_world()) + assert client["canon/testburg.json"]["body"].startswith("A test town") + assert client["canon/disposition-ladder.json"]["rungs"][0] == "hostile" + + +def test_check_secrecy_passes_on_valid_world(): + client, _ = build_trees(_world()) + check_secrecy(client, _world()) # no raise + + +def test_check_secrecy_fails_when_secret_body_reaches_client(): + world = _world() + client, _ = build_trees(world) + # simulate a routing regression: a secrecy-4 body in a client record + client["canon/leak.json"] = {"id": "secret.big-twist", "body": "leak"} + with pytest.raises(BuildError): + check_secrecy(client, world) + + +def test_write_trees_serializes_deterministically(tmp_path): + client, server = build_trees(_world()) + write_trees(client, server, tmp_path / "world", tmp_path / "server") + written = (tmp_path / "world" / "npcs" / "tess.json").read_text() + assert written == dump_json(client["npcs/tess.json"]) +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests/test_emit.py -q` +Expected: FAIL — `No module named 'content_build.emit'`. + +- [ ] **Step 4: Write `emit.py`** + +`tools/content_build/emit.py`: +```python +"""Route each canon entry's fields into the client tree (content/world) and the +server-only tree (content/server), per the schema's role split. build_trees is +pure (returns dicts); write_trees does the I/O; check_secrecy is the +after-routing defense-in-depth lint.""" + +import json +from pathlib import Path + +from .errors import BuildError +from .model import Entry +from .resolve import World +from .rungs import LADDER_ID + + +def dump_json(obj) -> str: + return json.dumps(obj, indent=2, sort_keys=True) + "\n" + + +def _client_relpath(e: Entry) -> str: + if e.is_npc_layer: + return f"npcs/{e.slug}.json" + if e.is_knowledge: + return f"topics/{e.slug}.json" + return f"canon/{e.slug}.json" + + +def _client_record(e: Entry) -> dict: + if e.is_npc_layer: + return { + "id": e.id, + "type": e.type, + "start_disposition": e.start_disposition, + "related": e.related, + "knows": [{"fact_id": k.fact_id, "gate": k.gate} for k in e.knows], + } + if e.is_knowledge: + return {"id": e.id, "type": e.type, "related": e.related} + rec = {"id": e.id, "type": e.type, "related": e.related} + if e.body is not None: + rec["body"] = e.body + if e.id == LADDER_ID and e.rungs: + rec["rungs"] = e.rungs + return rec + + +def _server_entry(e: Entry): + """Return (relpath, record) for the server tree, or None for canon entities + (their bodies are public and ship client-side).""" + if e.is_npc_layer: + rec = {"id": e.id, "persona": e.body} + if e.disposition_notes is not None: + rec["disposition_notes"] = e.disposition_notes + return f"npcs/{e.slug}.json", rec + if e.is_knowledge: + return f"topics/{e.slug}.json", {"id": e.id, "body": e.body, + "secrecy": e.secrecy} + return None + + +def build_trees(world: World) -> tuple[dict, dict]: + client: dict[str, dict] = {} + server: dict[str, dict] = {} + for e in world.entries: + if e.status != "canon": + continue + client[_client_relpath(e)] = _client_record(e) + se = _server_entry(e) + if se is not None: + relpath, rec = se + server[relpath] = rec + return client, server + + +def check_secrecy(client_files: dict, world: World) -> None: + for relpath, rec in client_files.items(): + if "body" in rec and world.by_id[rec["id"]].secrecy >= 3: + e = world.by_id[rec["id"]] + raise BuildError( + f"secrecy {e.secrecy} body routed to client artifact {relpath}", + source=e.source, entry_id=e.id) + + +def write_trees(client_files: dict, server_files: dict, + world_dir: Path, server_dir: Path) -> None: + for relpath, obj in client_files.items(): + p = Path(world_dir) / relpath + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(dump_json(obj)) + for relpath, obj in server_files.items(): + p = Path(server_dir) / relpath + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(dump_json(obj)) +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests/test_emit.py -q` +Expected: PASS (7 passed). + +- [ ] **Step 6: Commit** + +```bash +git add tools/content_build/emit.py tools/content_build/tests/fixtures/valid/world.md tools/content_build/tests/test_emit.py +git commit -m "feat(content-build): emit routing + secrecy lint + writer" +``` + +--- + +### Task 5: CLI orchestration and `--check` + +**Files:** +- Create: `tools/content_build/__main__.py` +- Test: `tools/content_build/tests/test_cli.py` + +**Interfaces:** +- Consumes: `parse_bible`, `entry_from_raw`, `resolve`, `build_trees`, `check_secrecy`, `write_trees`, `dump_json` (Tasks 1–4). +- Produces: `main(argv=None) -> int`; helpers `build(lore, world, server) -> None`, `check(lore, world, server) -> int`. CLI: `--check`, `--lore`, `--world`, `--server` (defaults `content/lore`, `content/world`, `content/server`, repo-root-relative). Exit 0 = ok, 1 = build failed or stale. + +- [ ] **Step 1: Write the failing CLI test** + +`tools/content_build/tests/test_cli.py`: +```python +import shutil +from pathlib import Path + +from content_build.__main__ import main + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "valid" + + +def _lore(tmp_path): + lore = tmp_path / "lore" + lore.mkdir() + shutil.copy(FIXTURE_DIR / "world.md", lore / "world.md") + return lore + + +def test_build_then_check_is_clean(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + assert (world / "npcs" / "tess.json").exists() + assert (server / "topics" / "big-twist.json").exists() + # a fresh --check against just-written output is clean + assert main(argv + ["--check"]) == 0 + + +def test_check_detects_stale(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + # mutate a committed artifact -> --check must fail + (world / "canon" / "testburg.json").write_text('{"id": "town.testburg"}\n') + assert main(argv + ["--check"]) == 1 + + +def test_check_detects_missing(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + (world / "npcs" / "tess.json").unlink() + assert main(argv + ["--check"]) == 1 + + +def test_invalid_bible_fails_build(tmp_path): + lore = tmp_path / "lore" + lore.mkdir() + (lore / "bad.md").write_text( + "```yaml\nid: town.a\ntype: town\nstatus: canon\nsecrecy: 0\n" + "related: [does.not-exist]\nbody: x\n```\n") + argv = ["--lore", str(lore), "--world", str(tmp_path / "w"), + "--server", str(tmp_path / "s")] + assert main(argv) == 1 +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests/test_cli.py -q` +Expected: FAIL — `ImportError` (no `main` in `content_build.__main__`). + +- [ ] **Step 3: Write `__main__.py`** + +`tools/content_build/__main__.py`: +```python +"""CLI entry: `python -m content_build [--check]`. Orchestrates +parse -> model -> resolve -> emit. --check builds in memory and diffs against +the on-disk committed JSON (stale-or-invalid -> exit 1).""" + +import argparse +import sys +from pathlib import Path + +from .emit import build_trees, check_secrecy, dump_json, write_trees +from .errors import BuildError +from .model import entry_from_raw +from .parse import parse_bible +from .resolve import resolve + + +def _load_world(lore_dir: Path): + raws = [] + for md in sorted(Path(lore_dir).glob("*.md")): + raws.extend(parse_bible(md)) + return resolve([entry_from_raw(r) for r in raws]) + + +def build(lore_dir, world_dir, server_dir) -> None: + world = _load_world(lore_dir) + client_files, server_files = build_trees(world) + check_secrecy(client_files, world) + write_trees(client_files, server_files, world_dir, server_dir) + + +def _diff(path: Path, obj) -> list[str]: + want = dump_json(obj) + if not path.exists(): + return [f"missing {path}"] + if path.read_text() != want: + return [f"differs {path}"] + return [] + + +def check(lore_dir, world_dir, server_dir) -> int: + world = _load_world(lore_dir) + client_files, server_files = build_trees(world) + check_secrecy(client_files, world) + stale: list[str] = [] + for relpath, obj in client_files.items(): + stale += _diff(Path(world_dir) / relpath, obj) + for relpath, obj in server_files.items(): + stale += _diff(Path(server_dir) / relpath, obj) + for s in stale: + print("STALE:", s, file=sys.stderr) + return 1 if stale else 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(prog="content_build") + ap.add_argument("--check", action="store_true", + help="verify committed JSON is fresh + valid; no writes") + ap.add_argument("--lore", default="content/lore") + ap.add_argument("--world", default="content/world") + ap.add_argument("--server", default="content/server") + args = ap.parse_args(argv) + try: + if args.check: + return check(args.lore, args.world, args.server) + build(args.lore, args.world, args.server) + return 0 + except BuildError as e: + print("BUILD FAILED:", e, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests/test_cli.py -q` +Expected: PASS (4 passed). + +- [ ] **Step 5: Run the whole tool suite** + +Run: `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests -q` +Expected: PASS (all tests green). + +- [ ] **Step 6: Commit** + +```bash +git add tools/content_build/__main__.py tools/content_build/tests/test_cli.py +git commit -m "feat(content-build): CLI build + --check staleness gate" +``` + +--- + +### Task 6: Promote Duncarrow (the acceptance run) + +**Files:** +- Rename: `candidate-town.md` → `content/lore/duncarrow.md` +- Create: `content/lore/canon-roadmap.md` +- Create (BUILT, committed): `content/world/canon/*.json`, `content/world/topics/*.json`, `content/world/npcs/{mera-fenn,mayor-oswin-crell,harn-blackwood}.json`, `content/server/npcs/*.json`, `content/server/topics/*.json` +- Test: `tools/content_build/tests/test_duncarrow.py` + +**Interfaces:** +- Consumes: the whole tool (Tasks 1–5). +- Produces: the committed Duncarrow artifacts; a green `--check`. + +- [ ] **Step 1: Move the specimen into the lore tree** + +```bash +git mv candidate-town.md content/lore/duncarrow.md +``` + +- [ ] **Step 2: Convert every fenced block to a `yaml` fence** + +In `content/lore/duncarrow.md`, change every block's opening fence from ` ``` ` to ` ```yaml ` (the parser only reads `yaml`-tagged blocks). Leave prose commentary and closing fences as-is. + +- [ ] **Step 3: Make each `knows` list valid YAML** + +Rewrite every `knows` entry from the one-line `- fact: X gate: Y` form to a proper mapping. Example (Mera Fenn's block becomes): + +```yaml +knows: + - {fact: rumor.elves-avoid-the-shrine, gate: neutral} + - {fact: rumor.travelers-go-missing, gate: warm} + - {fact: fact.militia-never-investigates, gate: warm} + - {fact: fact.crells-guard-acts-alone, gate: trusted} +``` + +Apply the same conversion to Mayor Crell's and Harn Blackwood's `knows` lists. + +- [ ] **Step 4: Apply the six specimen conformance fixes** + +1. **Drop `knows: town.duncarrow`** links from `npc.mayor-oswin-crell` and `npc.harn-blackwood` (a canon entity may not appear in a `knows` list; the town is public and spoken freely). +2. **Rename** `shrine.the-white-antlers` → `place.the-white-antlers` (id namespace must equal type), and update **every** `related` reference to it — in `town.duncarrow`, `rumor.elves-avoid-the-shrine`, `rumor.travelers-go-missing`, `secret.crell-runs-slave-trade`. +3. **Add two canon stubs** so all `related` ids resolve (paste as new `yaml` blocks under the WORLD RULE section): + +```yaml +id: region.the-tallow-reach +type: region +status: canon +secrecy: 0 +related: [] +body: > + The Tallow Reach — the lowland march of grain towns and wool roads that + Duncarrow sits at the edge of, feeding the pass trade. +``` + +```yaml +id: faction.elves +type: faction +status: canon +secrecy: 0 +related: [] +body: > + The elven travellers of the road — no single polity, but the people whose + passage through the White Antlers, and its recent absence, the town notices. +``` + +4. **Drop `secret.crell-runs-slave-trade`** from the **public** `person.mayor-oswin-crell` record's `related` (leave it `related: [town.duncarrow]`). The secret still links *to* the mayor, and the `npc.mayor-oswin-crell` layer still holds it at `gate: never` — that is the real bounded-move test. +5. **Add `rungs:`** to `rule.disposition-ladder` (keep the existing prose `body`): + +```yaml +id: rule.disposition-ladder +type: rule +status: canon +secrecy: 0 +related: [] +rungs: [hostile, cold, neutral, warm, trusted] +body: > + NPC trust toward the player is one of five ordered states: + hostile < cold < neutral < warm < trusted. ... +``` + +6. **Flip every entry** `status: candidate` → `status: canon` (all of them — Duncarrow promotes atomically). + +- [ ] **Step 5: Write the canon roadmap index** + +`content/lore/canon-roadmap.md`: +```markdown +# Canon Roadmap + +The world's answer to `docs/roadmap.md`: what is authored vs pending, and the +order the Margreave is being written. Source of truth is `content/lore/*.md`; +`content/world` + `content/server` are BUILT from it (`python -m content_build`). + +## Authored (status: canon) + +- **duncarrow.md** — Duncarrow (the Tallow Reach market town), the White Antlers + shrine, the Crell secret chain, and NPCs Mera Fenn (witness), Mayor Crell + (control), Harn Blackwood (texture). The bounded-dialogue / disposition-gate + specimen. + +## Pending (not yet authored) + +- The rest of the Tallow Reach region; the elven road-peoples beyond the stub + `faction.elves`; the seven worldbuilding topics tracked in the + canon-architecture direction. +``` + +- [ ] **Step 6: Run the build and fix any validation errors** + +Run: `PYTHONPATH=tools .venv/bin/python -m content_build` +Expected: exit 0, no `BUILD FAILED` output. If it reports an error, the message names the entry id + `duncarrow.md:` — fix that block and re-run. + +- [ ] **Step 7: Write the round-trip acceptance test** + +`tools/content_build/tests/test_duncarrow.py`: +```python +"""End-to-end: the real promoted Duncarrow bible builds green and routes the +Crell secret correctly (body server-only; the id ships in the gate skeleton).""" + +import json +from pathlib import Path + +from content_build.__main__ import _load_world, check +from content_build.emit import build_trees + +REPO = Path(__file__).resolve().parents[3] +LORE = REPO / "content" / "lore" +WORLD = REPO / "content" / "world" +SERVER = REPO / "content" / "server" + + +def test_duncarrow_builds_and_check_is_clean(): + assert check(LORE, WORLD, SERVER) == 0 + + +def test_secret_body_is_server_only(): + client, server = build_trees(_load_world(LORE)) + assert "topics/crell-runs-slave-trade.json" in server + # the secret's descriptive id ships in the gate skeleton (schema property)... + npc = client["npcs/mayor-oswin-crell.json"] + assert any(k["fact_id"] == "secret.crell-runs-slave-trade" + and k["gate"] == "never" for k in npc["knows"]) + # ...but its BODY never reaches any client artifact + blob = json.dumps(client) + assert "elf-slaving" not in blob and "sells them into slavery" not in blob + + +def test_mera_never_holds_the_secret(): + client, _ = build_trees(_load_world(LORE)) + fenn = client["npcs/mera-fenn.json"] + facts = {k["fact_id"] for k in fenn["knows"]} + assert "secret.crell-runs-slave-trade" not in facts + assert "fact.crells-guard-acts-alone" in facts +``` + +- [ ] **Step 8: Run the acceptance test** + +Run: `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests/test_duncarrow.py -q` +Expected: PASS (3 passed). If `test_secret_body_is_server_only` fails on the `elf-slaving`/`slavery` assertion, the secret body leaked into a client artifact — a real bug; stop and fix routing. + +- [ ] **Step 9: Commit the promoted bible + built artifacts** + +```bash +git add content/lore/duncarrow.md content/lore/canon-roadmap.md content/world content/server tools/content_build/tests/test_duncarrow.py +git commit -m "feat(content): promote Duncarrow; build client/server artifacts" +``` + +--- + +### Task 7: Client loader tolerates the new `canon/` + `topics/` dirs + +**Files:** +- Modify: `client/scripts/content/content_db.gd` +- Test: `client/tests/unit/test_content_db.gd` + +**Interfaces:** +- Consumes: the built `content/world/{canon,topics}/*.json` (Task 6). +- Produces: `ContentDB` gains `canon_entities: Dictionary`, `topics: Dictionary`, and `canon(id)/topic(id)/has_canon(id)/has_topic(id)` accessors. The backing var is `canon_entities` (not `canon`) because GDScript forbids a var and a method sharing a name — mirroring the existing `var locations` / `func location` pairing. Existing dirs + legacy `fenn.json` unchanged. + +- [ ] **Step 1: Write the failing loader tests** + +Append to `client/tests/unit/test_content_db.gd`: +```gdscript +func test_loads_canon_and_topics(): + assert_true(db.has_canon("town.duncarrow")) + assert_true(db.has_canon("place.the-white-antlers")) + assert_true(db.has_topic("rumor.travelers-go-missing")) + + +func test_topic_skeleton_has_no_body(): + var t := db.topic("secret.crell-runs-slave-trade") + assert_false(t.has("body")) # bodies are server-only + + +func test_legacy_npcs_still_load(): + assert_true(db.has_npc("fenn")) + assert_true(db.has_npc("mera-fenn")) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_content_db.gd` +Expected: FAIL — `has_canon`/`has_topic` not found (or the dicts are empty). + +- [ ] **Step 3: Add the two dirs and accessors to `content_db.gd`** + +In `client/scripts/content/content_db.gd`, add two vars after the existing `items` var (line ~10): +```gdscript +var canon_entities: Dictionary = {} +var topics: Dictionary = {} +``` + +In `load_from()`, after the existing `items = _load_dir(...)` line, add: +```gdscript + canon_entities = _load_dir(world.path_join("canon")) + topics = _load_dir(world.path_join("topics")) +``` + +Add accessors next to the existing `location()/npc()/...` accessors (note the +backing var `canon_entities` — a `func canon` cannot share a name with its var): +```gdscript +func canon(id: String) -> Dictionary: return canon_entities.get(id, {}) +func topic(id: String) -> Dictionary: return topics.get(id, {}) +func has_canon(id: String) -> bool: return canon_entities.has(id) +func has_topic(id: String) -> bool: return topics.has(id) +``` + +Note: `_load_dir` already `push_error`s and skips a missing dir, so this stays safe if `canon/`/`topics/` are ever absent. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_content_db.gd` +Expected: PASS — the new tests green, existing `test_content_db` tests still green. + +- [ ] **Step 5: Run the full client suite to confirm no regressions** + +Run: `cd client && ./run_tests.sh` +Expected: PASS (no new failures). + +- [ ] **Step 6: Commit** + +```bash +git add client/scripts/content/content_db.gd client/tests/unit/test_content_db.gd +git commit -m "feat(client): ContentDB loads canon/ + topics/ built dirs" +``` + +--- + +### Task 8: CI gate + +**Files:** +- Create: `.github/workflows/content-build.yml` + +**Interfaces:** +- Consumes: the tool + committed artifacts (Tasks 1–6). +- Produces: a CI job that runs the tool tests and `--check`. + +> **Note:** the repo has no CI yet. This adds a minimal GitHub Actions workflow. If the project uses a different CI provider, port these two commands (`pytest tools/content_build/tests` and `python -m content_build --check`) to it and skip this file. + +- [ ] **Step 1: Write the workflow** + +`.github/workflows/content-build.yml`: +```yaml +name: content-build +on: + push: + paths: ["content/**", "tools/content_build/**", ".github/workflows/content-build.yml"] + pull_request: + paths: ["content/**", "tools/content_build/**"] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -r tools/requirements-dev.txt + - name: Unit tests + run: PYTHONPATH=tools python -m pytest tools/content_build/tests -q + - name: Content freshness + validity + run: PYTHONPATH=tools python -m content_build --check +``` + +- [ ] **Step 2: Sanity-run the two CI commands locally** + +Run: +```bash +PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests -q +PYTHONPATH=tools .venv/bin/python -m content_build --check +``` +Expected: both exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/content-build.yml +git commit -m "ci: content-build tests + --check gate" +``` + +--- + +## Done criteria + +- `PYTHONPATH=tools .venv/bin/python -m pytest tools/content_build/tests -q` → all green (parse, model, resolve, emit, cli, duncarrow). +- `PYTHONPATH=tools .venv/bin/python -m content_build --check` → exit 0. +- `content/lore/duncarrow.md` + `content/lore/canon-roadmap.md` exist; `candidate-town.md` is gone. +- Built `content/world/{canon,topics,npcs}` + `content/server/{npcs,topics}` committed; the Crell secret **body** appears only under `content/server/`. +- `cd client && ./run_tests.sh` → green, legacy NPCs still load. +- Then: **superpowers:finishing-a-development-branch** to smoke-test and (on the human's confirmation, per §18) merge `feature/content-build-tool` → `dev` with `--no-ff`. + +## Out of scope (next cycles — do NOT build here) + +- Runtime consumption: client computing gated `available_moves` from the new npc skeletons; `api` reading personas from `content/server/`; voicing Mera Fenn end to end. +- Legacy `fenn.json` migration to a bible. +- Godot export packaging (include `world/`, exclude `server/`). +- Opaque topic ids (the descriptive-slug spoiler property is inherent to the locked schema — noted, not fixed). +```