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:
112
.claude/skills/world-building/scripts/canon_index.py
Normal file
112
.claude/skills/world-building/scripts/canon_index.py
Normal 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())
|
||||
Reference in New Issue
Block a user