518 lines
16 KiB
Python
Executable File
518 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate Codex Game Studios plugin structure and package hygiene."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FORBIDDEN_NAMES = {".DS_Store", ".env", "plan.md"}
|
|
FORBIDDEN_PARTS = {".git", "__pycache__"}
|
|
EXPECTED_SKILLS = {
|
|
"art-bible",
|
|
"brainstorm",
|
|
"setup-engine",
|
|
}
|
|
REQUIRED_RUNTIME_AGENTS = {
|
|
"art-director.toml",
|
|
"creative-director.toml",
|
|
"godot-csharp-specialist.toml",
|
|
"godot-gdextension-specialist.toml",
|
|
"godot-gdscript-specialist.toml",
|
|
"godot-shader-specialist.toml",
|
|
"godot-specialist.toml",
|
|
"producer.toml",
|
|
"technical-director.toml",
|
|
"technical-artist.toml",
|
|
"ue-blueprint-specialist.toml",
|
|
"ue-gas-specialist.toml",
|
|
"ue-replication-specialist.toml",
|
|
"ue-umg-specialist.toml",
|
|
"unity-addressables-specialist.toml",
|
|
"unity-dots-specialist.toml",
|
|
"unity-shader-specialist.toml",
|
|
"unity-specialist.toml",
|
|
"unity-ui-specialist.toml",
|
|
"unreal-specialist.toml",
|
|
"ux-designer.toml",
|
|
}
|
|
DIRECTOR_RUNTIME_AGENTS = {
|
|
"art-director.toml",
|
|
"creative-director.toml",
|
|
"producer.toml",
|
|
"technical-director.toml",
|
|
}
|
|
EXPECTED_ENGINES = {
|
|
"godot",
|
|
"unity",
|
|
"unreal",
|
|
}
|
|
REQUIRED_ENGINE_REFERENCE_FILES = {
|
|
"VERSION.md",
|
|
"breaking-changes.md",
|
|
"current-best-practices.md",
|
|
"deprecated-apis.md",
|
|
}
|
|
FORBIDDEN_UNAVAILABLE_WORKFLOW_REFS = {
|
|
"$architecture-decision",
|
|
"$architecture-review",
|
|
"$code-review",
|
|
"$design-system",
|
|
"$dev-story",
|
|
"$map-systems",
|
|
"$propagate-design-change",
|
|
"$prototype",
|
|
"$sprint-plan",
|
|
"$team-ui",
|
|
"$test-setup",
|
|
"$ux-design",
|
|
"$ux-review",
|
|
"/gate-check",
|
|
}
|
|
FORBIDDEN_UNAVAILABLE_ROLE_REFS = {
|
|
"accessibility-specialist",
|
|
"audio-director",
|
|
"devops-engineer",
|
|
"engine-programmer",
|
|
"game-designer",
|
|
"gameplay-programmer",
|
|
"lead-programmer",
|
|
"level-designer",
|
|
"localization-lead",
|
|
"network-programmer",
|
|
"performance-analyst",
|
|
"prototyper",
|
|
"qa-lead",
|
|
"qa-tester",
|
|
"security-engineer",
|
|
"sound-designer",
|
|
"systems-designer",
|
|
"tools-programmer",
|
|
"ui-programmer",
|
|
"world-builder",
|
|
"writer",
|
|
}
|
|
|
|
|
|
def fail(message: str) -> None:
|
|
print(f"ERROR: {message}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
|
|
def run_git(args: list[str]) -> list[str]:
|
|
result = subprocess.run(
|
|
["git", *args],
|
|
cwd=ROOT,
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(result.stderr.strip() or "git command failed")
|
|
return [line for line in result.stdout.splitlines() if line]
|
|
|
|
|
|
def assert_manifest() -> None:
|
|
manifest_path = ROOT / ".codex-plugin" / "plugin.json"
|
|
if not manifest_path.is_file():
|
|
fail("Missing .codex-plugin/plugin.json")
|
|
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
if manifest.get("name") != "codex-game-studios":
|
|
fail("plugin.json name must be codex-game-studios")
|
|
if manifest.get("skills") != "./skills/":
|
|
fail('plugin.json must expose skills via "./skills/"')
|
|
|
|
for prompt in manifest.get("interface", {}).get("defaultPrompt", []):
|
|
if len(prompt) > 128:
|
|
fail(f"defaultPrompt exceeds 128 chars: {prompt}")
|
|
|
|
|
|
def assert_skill(skill_name: str) -> None:
|
|
skill_dir = ROOT / "skills" / skill_name
|
|
skill_path = skill_dir / "SKILL.md"
|
|
details_path = skill_dir / "DETAILS.md"
|
|
agent_path = skill_dir / "agents" / "openai.yaml"
|
|
|
|
if not skill_path.is_file():
|
|
fail(f"Missing skill file: {skill_path.relative_to(ROOT)}")
|
|
if not details_path.is_file():
|
|
fail(f"Missing skill details file: {details_path.relative_to(ROOT)}")
|
|
if not agent_path.is_file():
|
|
fail(f"Missing skill UI metadata: {agent_path.relative_to(ROOT)}")
|
|
|
|
text = skill_path.read_text(encoding="utf-8")
|
|
if not text.startswith("---\n"):
|
|
fail(f"Missing YAML frontmatter: {skill_path.relative_to(ROOT)}")
|
|
if f"name: {skill_name}" not in text:
|
|
fail(f"Skill name mismatch in {skill_path.relative_to(ROOT)}")
|
|
if "description:" not in text.split("---", 2)[1]:
|
|
fail(f"Missing skill description in {skill_path.relative_to(ROOT)}")
|
|
|
|
|
|
def assert_skill_set() -> None:
|
|
present = {
|
|
path.parent.name
|
|
for path in (ROOT / "skills").glob("*/SKILL.md")
|
|
}
|
|
if present != EXPECTED_SKILLS:
|
|
fail(
|
|
"Skill set mismatch. Expected exactly: "
|
|
+ ", ".join(sorted(EXPECTED_SKILLS))
|
|
+ "; found: "
|
|
+ ", ".join(sorted(present))
|
|
)
|
|
|
|
|
|
def assert_runtime_agents() -> None:
|
|
agent_dir = ROOT / "runtime" / "agents"
|
|
if not agent_dir.is_dir():
|
|
fail("Missing runtime/agents directory")
|
|
|
|
present = {path.name for path in agent_dir.glob("*.toml")}
|
|
missing = sorted(REQUIRED_RUNTIME_AGENTS - present)
|
|
if missing:
|
|
fail(f"Missing runtime agents: {', '.join(missing)}")
|
|
|
|
for agent_name in sorted(REQUIRED_RUNTIME_AGENTS):
|
|
text = (agent_dir / agent_name).read_text(encoding="utf-8")
|
|
try:
|
|
parsed = tomllib.loads(text)
|
|
except tomllib.TOMLDecodeError as error:
|
|
fail(f"{agent_name} is not valid TOML: {error}")
|
|
for field_name in ["name", "description", "developer_instructions"]:
|
|
if field_name not in parsed:
|
|
fail(f"{agent_name} missing TOML field: {field_name}")
|
|
if "specialist" in agent_name and "Version Awareness" not in text:
|
|
fail(f"{agent_name} missing Version Awareness instructions")
|
|
|
|
forbidden_roles = sorted(
|
|
role for role in FORBIDDEN_UNAVAILABLE_ROLE_REFS if role in text
|
|
)
|
|
if agent_name not in DIRECTOR_RUNTIME_AGENTS and forbidden_roles:
|
|
fail(
|
|
f"{agent_name} references unavailable role agents: "
|
|
+ ", ".join(forbidden_roles)
|
|
)
|
|
forbidden_workflows = sorted(
|
|
ref for ref in FORBIDDEN_UNAVAILABLE_WORKFLOW_REFS if ref in text
|
|
)
|
|
if agent_name not in DIRECTOR_RUNTIME_AGENTS and forbidden_workflows:
|
|
fail(
|
|
f"{agent_name} references unavailable workflows: "
|
|
+ ", ".join(forbidden_workflows)
|
|
)
|
|
|
|
|
|
def assert_project_template() -> None:
|
|
template_path = ROOT / "project-template" / "AGENTS.md"
|
|
tech_template_path = ROOT / "project-template" / "technical-preferences.md"
|
|
if not template_path.is_file():
|
|
fail("Missing project-template/AGENTS.md")
|
|
if not tech_template_path.is_file():
|
|
fail("Missing project-template/technical-preferences.md")
|
|
|
|
text = template_path.read_text(encoding="utf-8")
|
|
required_fragments = [
|
|
"Codex Game Studios Project Guide",
|
|
"production/review-mode.txt",
|
|
".codex/agents/",
|
|
"design/gdd/game-concept.md",
|
|
"design/gdd/game-pillars.md",
|
|
"docs/technical-preferences.md",
|
|
"docs/engine-reference/<engine>/VERSION.md",
|
|
"$setup-engine",
|
|
]
|
|
for fragment in required_fragments:
|
|
if fragment not in text:
|
|
fail(f"project-template/AGENTS.md missing required content: {fragment}")
|
|
legacy_import = "@." + "cla" + "ude"
|
|
for fragment in [legacy_import, "coordination-rules", "coding-standards"]:
|
|
if fragment in text:
|
|
fail(f"project-template/AGENTS.md contains unavailable global import: {fragment}")
|
|
forbidden_workflows = sorted(
|
|
ref for ref in FORBIDDEN_UNAVAILABLE_WORKFLOW_REFS if ref in text
|
|
)
|
|
if forbidden_workflows:
|
|
fail(
|
|
"project-template/AGENTS.md references unavailable workflows: "
|
|
+ ", ".join(forbidden_workflows)
|
|
)
|
|
|
|
tech_text = tech_template_path.read_text(encoding="utf-8")
|
|
required_tech_fragments = [
|
|
"# Technical Preferences",
|
|
"## Engine & Language",
|
|
"## Input & Platform",
|
|
"## Engine Specialists",
|
|
"File Extension Routing",
|
|
]
|
|
for fragment in required_tech_fragments:
|
|
if fragment not in tech_text:
|
|
fail(
|
|
"project-template/technical-preferences.md missing required "
|
|
f"content: {fragment}"
|
|
)
|
|
forbidden_tech_workflows = sorted(
|
|
ref for ref in FORBIDDEN_UNAVAILABLE_WORKFLOW_REFS if ref in tech_text
|
|
)
|
|
if forbidden_tech_workflows:
|
|
fail(
|
|
"project-template/technical-preferences.md references unavailable "
|
|
"workflows: " + ", ".join(forbidden_tech_workflows)
|
|
)
|
|
|
|
setup_details = ROOT / "skills" / "setup-engine" / "DETAILS.md"
|
|
if setup_details.is_file():
|
|
setup_text = setup_details.read_text(encoding="utf-8")
|
|
required_setup_fragments = [
|
|
"technical utility skill",
|
|
"has no director gates",
|
|
"Existing Configuration Check",
|
|
"Specific section only",
|
|
"May I write these changes to `docs/technical-preferences.md`?",
|
|
"Rendering and Physics",
|
|
"Verdict: COMPLETE",
|
|
]
|
|
for fragment in required_setup_fragments:
|
|
if fragment not in setup_text:
|
|
fail(
|
|
"skills/setup-engine/DETAILS.md missing setup-engine "
|
|
f"behavior: {fragment}"
|
|
)
|
|
forbidden_setup_workflows = sorted(
|
|
ref for ref in FORBIDDEN_UNAVAILABLE_WORKFLOW_REFS if ref in setup_text
|
|
)
|
|
if forbidden_setup_workflows:
|
|
fail(
|
|
"skills/setup-engine/DETAILS.md references unavailable workflows: "
|
|
+ ", ".join(forbidden_setup_workflows)
|
|
)
|
|
|
|
|
|
def assert_art_bible_workflow() -> None:
|
|
details_path = ROOT / "skills" / "art-bible" / "DETAILS.md"
|
|
template_path = ROOT / "references" / "studio-docs" / "templates" / "art-bible.md"
|
|
|
|
if not details_path.is_file():
|
|
fail("Missing skills/art-bible/DETAILS.md")
|
|
if not template_path.is_file():
|
|
fail("Missing references/studio-docs/templates/art-bible.md")
|
|
|
|
details_text = details_path.read_text(encoding="utf-8")
|
|
required_details = [
|
|
"design/gdd/game-concept.md",
|
|
"design/art/art-bible.md",
|
|
"docs/technical-preferences.md",
|
|
"Retrofit Mode",
|
|
"AD-ART-BIBLE",
|
|
"Codex subagent workflow",
|
|
"May I write section",
|
|
"technical-artist",
|
|
"ux-designer",
|
|
"$setup-engine",
|
|
"Stop here",
|
|
"Verdict: COMPLETE",
|
|
]
|
|
for fragment in required_details:
|
|
if fragment not in details_text:
|
|
fail(
|
|
"skills/art-bible/DETAILS.md missing art-bible behavior: "
|
|
f"{fragment}"
|
|
)
|
|
|
|
forbidden_details = [
|
|
"/map-systems",
|
|
"/design-system",
|
|
"/asset-spec",
|
|
"/consistency-check",
|
|
"/create-architecture",
|
|
"Ask" + "User" + "Question",
|
|
]
|
|
present_forbidden_details = sorted(
|
|
fragment for fragment in forbidden_details if fragment in details_text
|
|
)
|
|
if present_forbidden_details:
|
|
fail(
|
|
"skills/art-bible/DETAILS.md references unavailable or non-native "
|
|
"workflow terms: "
|
|
+ ", ".join(present_forbidden_details)
|
|
)
|
|
|
|
forbidden_workflows = sorted(
|
|
ref for ref in FORBIDDEN_UNAVAILABLE_WORKFLOW_REFS if ref in details_text
|
|
)
|
|
if forbidden_workflows:
|
|
fail(
|
|
"skills/art-bible/DETAILS.md references unavailable workflows: "
|
|
+ ", ".join(forbidden_workflows)
|
|
)
|
|
|
|
template_text = template_path.read_text(encoding="utf-8")
|
|
required_template = [
|
|
"Visual Identity Statement",
|
|
"Mood & Atmosphere",
|
|
"Shape Language",
|
|
"Color System",
|
|
"Character Design Direction",
|
|
"Environment Design Language",
|
|
"UI/HUD Visual Direction",
|
|
"Asset Standards",
|
|
"Reference Direction",
|
|
"AD-ART-BIBLE",
|
|
"$setup-engine",
|
|
"Stop here",
|
|
]
|
|
for fragment in required_template:
|
|
if fragment not in template_text:
|
|
fail(
|
|
"references/studio-docs/templates/art-bible.md missing required "
|
|
f"content: {fragment}"
|
|
)
|
|
|
|
|
|
def assert_engine_references() -> None:
|
|
reference_root = ROOT / "references" / "engine-reference"
|
|
if not reference_root.is_dir():
|
|
fail("Missing references/engine-reference directory")
|
|
|
|
present = {path.name for path in reference_root.iterdir() if path.is_dir()}
|
|
missing_engines = sorted(EXPECTED_ENGINES - present)
|
|
if missing_engines:
|
|
fail(f"Missing engine reference baselines: {', '.join(missing_engines)}")
|
|
|
|
for engine in sorted(EXPECTED_ENGINES):
|
|
engine_dir = reference_root / engine
|
|
missing_files = sorted(
|
|
file_name
|
|
for file_name in REQUIRED_ENGINE_REFERENCE_FILES
|
|
if not (engine_dir / file_name).is_file()
|
|
)
|
|
if missing_files:
|
|
fail(
|
|
f"Missing {engine} reference files: "
|
|
+ ", ".join(missing_files)
|
|
)
|
|
if not (engine_dir / "modules").is_dir():
|
|
fail(f"Missing {engine} reference modules directory")
|
|
|
|
technical_template = (
|
|
ROOT / "references" / "studio-docs" / "templates" / "technical-preferences.md"
|
|
)
|
|
if not technical_template.is_file():
|
|
fail("Missing references/studio-docs/templates/technical-preferences.md")
|
|
technical_template_text = technical_template.read_text(encoding="utf-8")
|
|
forbidden_template_workflows = sorted(
|
|
ref
|
|
for ref in FORBIDDEN_UNAVAILABLE_WORKFLOW_REFS
|
|
if ref in technical_template_text
|
|
)
|
|
if forbidden_template_workflows:
|
|
fail(
|
|
"references/studio-docs/templates/technical-preferences.md "
|
|
"references unavailable workflows: "
|
|
+ ", ".join(forbidden_template_workflows)
|
|
)
|
|
|
|
|
|
def assert_runtime_installer() -> None:
|
|
installer_path = ROOT / "scripts" / "install_codex_runtime.py"
|
|
if not installer_path.is_file():
|
|
fail("Missing scripts/install_codex_runtime.py")
|
|
|
|
text = installer_path.read_text(encoding="utf-8")
|
|
required_fragments = [
|
|
"PROJECT_AGENTS_TEMPLATE",
|
|
"project-template",
|
|
"AGENTS.md",
|
|
"--force-agents-md",
|
|
]
|
|
for fragment in required_fragments:
|
|
if fragment not in text:
|
|
fail(f"install_codex_runtime.py missing required content: {fragment}")
|
|
|
|
|
|
def assert_package_hygiene() -> None:
|
|
try:
|
|
tracked = run_git(["ls-files"])
|
|
untracked = run_git(["ls-files", "--others", "--exclude-standard"])
|
|
candidates = tracked + untracked
|
|
except RuntimeError:
|
|
candidates = [
|
|
str(path.relative_to(ROOT))
|
|
for path in ROOT.rglob("*")
|
|
if path.is_file() or path.is_dir()
|
|
]
|
|
|
|
bad: list[str] = []
|
|
for rel_path in candidates:
|
|
path = Path(rel_path)
|
|
if path.name in FORBIDDEN_NAMES or FORBIDDEN_PARTS.intersection(path.parts):
|
|
bad.append(rel_path)
|
|
|
|
if bad:
|
|
fail("Forbidden package candidates:\n- " + "\n- ".join(sorted(bad)))
|
|
|
|
|
|
def assert_native_codex_language() -> None:
|
|
try:
|
|
tracked = run_git(["ls-files"])
|
|
untracked = run_git(["ls-files", "--others", "--exclude-standard"])
|
|
candidates = tracked + untracked
|
|
except RuntimeError:
|
|
candidates = [
|
|
str(path.relative_to(ROOT))
|
|
for path in ROOT.rglob("*")
|
|
if path.is_file()
|
|
]
|
|
|
|
blocked_terms = [
|
|
"C" + "laude",
|
|
"C" + "LAUDE",
|
|
"." + "cla" + "ude",
|
|
]
|
|
bad: list[str] = []
|
|
for rel_path in candidates:
|
|
path = ROOT / rel_path
|
|
if not path.is_file():
|
|
continue
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except UnicodeDecodeError:
|
|
continue
|
|
for term in blocked_terms:
|
|
if term in text:
|
|
bad.append(rel_path)
|
|
break
|
|
|
|
if bad:
|
|
fail(
|
|
"Files contain non-native Codex terminology:\n- "
|
|
+ "\n- ".join(sorted(bad))
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
assert_manifest()
|
|
assert_skill_set()
|
|
for skill_name in sorted(EXPECTED_SKILLS):
|
|
assert_skill(skill_name)
|
|
assert_runtime_agents()
|
|
assert_project_template()
|
|
assert_art_bible_workflow()
|
|
assert_engine_references()
|
|
assert_runtime_installer()
|
|
assert_package_hygiene()
|
|
assert_native_codex_language()
|
|
print("Codex Game Studios plugin validation passed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|