feat(skills): add project-local world-building skill

Author gritty, disposition-gated Margreave content (lore bible + hand-authored
quest/item/location/origin JSON) that reconciles against existing canon before
writing, holds the world's hard-by-default / warm-when-earned NPC tone, and keeps
the content_build --check gate green.

- SKILL.md: read-before-write workflow, read-only on existing canon (propose
  diffs, require approval), build+check+pytest as the done bar.
- references/schema.md: full bible + JSON contract, secrecy scale, gate model.
- references/tone.md: tone contract + NPC disposition-warmth model.
- scripts/canon_index.py: dumps existing ids for reconciliation.
- evals/: 3 test cases (all green in isolated-worktree runs).

Un-ignore /.claude/skills/ so project skills are versioned like code; all other
.claude/ dirs (root + nested api/client) stay ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
This commit is contained in:
2026-07-12 11:15:34 -05:00
parent a3506f7f02
commit b15bd2bb1e
6 changed files with 643 additions and 1 deletions

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Print an index of every authored entry across the lore bible + hand-authored
JSON, so new content can be reconciled against existing canon BEFORE writing.
Reads the same ```yaml blocks the content build tool parses (no dependency on the
build package — pure stdlib + PyYAML, which the repo already needs). Run from the
repo root:
python .claude/skills/world-building/scripts/canon_index.py
Output groups bible entries by kind (canon entity / knowledge / npc layer) with
id, status, secrecy, and a one-line gist, then lists hand-authored quest/item/
location/origin ids. Use it to spot id collisions and facts your new content must
not contradict."""
import json
import sys
from pathlib import Path
try:
import yaml
except ImportError:
sys.exit("PyYAML not installed; run: pip install pyyaml")
REPO = Path(__file__).resolve().parents[4]
LORE = REPO / "content" / "lore"
WORLD = REPO / "content" / "world"
ORIGINS = REPO / "content" / "origins"
CANON_TYPES = {"town", "place", "region", "faction", "person", "rule"}
KNOWLEDGE_TYPES = {"rumor", "fact", "secret"}
def parse_bible(path: Path):
"""Yield yaml-block mappings, mirroring tools/content_build/parse.py."""
lines = path.read_text().splitlines()
i, n = 0, len(lines)
while i < n:
if lines[i].strip() == "```yaml":
i += 1
block = []
while i < n and lines[i].strip() != "```":
block.append(lines[i])
i += 1
data = yaml.safe_load("\n".join(block))
if isinstance(data, dict):
yield data, path.name
i += 1
def gist(text) -> str:
if not text:
return ""
one = " ".join(str(text).split())
return one[:100] + ("" if len(one) > 100 else "")
def main() -> int:
if not LORE.is_dir():
return print(f"no lore dir at {LORE}") or 1
entities, knowledge, npcs = [], [], []
for md in sorted(LORE.glob("*.md")):
for d, src in parse_bible(md):
eid = d.get("id", "?")
ns = str(eid).split(".", 1)[0]
row = (eid, d.get("status", "?"), d.get("secrecy", "?"),
gist(d.get("body")), src)
if ns == "npc":
gates = ", ".join(f"{k.get('fact')}@{k.get('gate')}"
for k in d.get("knows", []))
npcs.append(row + (d.get("start_disposition", "?"), gates))
elif ns in KNOWLEDGE_TYPES:
knowledge.append(row)
else:
entities.append(row)
print("=== CANON ENTITIES (public; body ships client) ===")
for eid, st, sec, g, src in sorted(entities):
print(f" {eid:34} [{st} sec{sec}] {src}\n {g}")
print("\n=== KNOWLEDGE (rumor/fact/secret; body server-only) ===")
for eid, st, sec, g, src in sorted(knowledge):
print(f" {eid:34} [{st} sec{sec}] {src}\n {g}")
print("\n=== NPC DIALOGUE LAYERS ===")
for eid, st, sec, g, src, start, gates in sorted(npcs):
print(f" {eid:34} [{st} start:{start}] {src}\n persona: {g}\n"
f" knows: {gates}")
def list_json(label, d):
print(f"\n=== {label} (hand-authored JSON) ===")
if not d.is_dir():
print(" (none)")
return
for f in sorted(d.glob("*.json")):
try:
o = json.loads(f.read_text())
name = o.get("name") or o.get("display_name") or ""
except (OSError, ValueError):
name = "(unreadable)"
print(f" {o.get('id', f.stem):28} {name}")
list_json("QUESTS", WORLD / "quests")
list_json("ITEMS", WORLD / "items")
list_json("LOCATIONS", WORLD / "locations")
list_json("ORIGINS", ORIGINS)
return 0
if __name__ == "__main__":
raise SystemExit(main())