Automation
Hook
Hooks
What godotmaker runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
$ npx -y skills add RandallLiuXin/GodotMaker --agent claude-codeShips with godotmaker. Installing the plugin gets these hooks.
Where it lives
- hooks/__init__.pyGitHub
Read the script
"""Hook modules used by GodotMaker runtime checks."""
- hooks/check_asset_access.pyGitHub
Read the script
#!/usr/bin/env python3 """PreToolUse hook: block active pipeline roles from reading asset images. The active /gm-* skill in the main session must delegate asset analysis to an analyst subagent. Regular coding-agent conversations with no current_role are not pipeline sessions and are allowed to read assets directly. Subagents are also allowed; only the main agent (empty agent_id) is blocked during a pipeline role. """ import json import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from metrics import get_current_role IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif", ".bmp", ".tga"} def main(): try: data = json.load(sys.stdin) except (json.JSONDecodeError, EOFError): sys.exit(0) if data.get("hook_event_name") != "PreToolUse": sys.exit(0) if data.get("tool_name") != "Read": sys.exit(0) agent_id = data.get("agent_id", "") if agent_id: sys.exit(0) if not get_current_role(): sys.exit(0) tool_input = data.get("tool_input", {}) file_path = tool_input.get("file_path", "") if not file_path: sys.exit(0) normalized = file_path.replace("\\", "/").lower() if "/assets/" not in normalized and not normalized.startswith("assets/"): sys.exit(0) _, ext = os.path.splitext(normalized) if ext not in IMAGE_EXTENSIONS: sys.exit(0) reason = ( f"The active pipeline role cannot read image files in assets/ directly. " f"Dispatch an analyst subagent to analyze '{os.path.basename(file_path)}' instead. " f"See analyst-dispatch.md for the protocol." ) print(json.dumps({"hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason, }})) sys.exit(0) if __name__ == "__main__": main() - hooks/check_clean_workspace.pyGitHub
Read the script
#!/usr/bin/env python3 """Stop hook: warn the agent once per dirty episode at end of a SKILL. Block-once per dirty episode — agent gets reminded, can commit/clean/ignore. Clean state resets the flag so the next dirty episode gets its own reminder. Subagents skipped. """ import json import os import subprocess import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from metrics import get_current_role, state def main(): try: data = json.load(sys.stdin) except (json.JSONDecodeError, EOFError): sys.exit(0) if data.get("agent_id"): sys.exit(0) role = get_current_role() if not role: sys.exit(0) # Role change between Stop events resets the reminder flag, so the new # role gets its own first-dirty-stop reminder instead of inheriting the # previous role's "already reminded" state. if state.get("dirty_reminder_last_role", None) != role: state.put("dirty_reminded", False) state.put("dirty_reminder_last_role", role) r = subprocess.run( ["git", "status", "--porcelain"], capture_output=True, text=True ) if r.returncode != 0: sys.exit(0) output = r.stdout.strip() if not output: state.put("dirty_reminded", False) sys.exit(0) if state.get("dirty_reminded", False): sys.exit(0) state.put("dirty_reminded", True) snippet = output[:800] + ("\n... (truncated)" if len(output) > 800 else "") reminder = ( f"Working tree dirty at end of '{role}':\n{snippet}\n\n" f'Run `git add -A && git commit -m "..."`, remove unintended files, ' f"or exit again to skip." ) print(json.dumps({"decision": "block", "reason": reminder})) sys.exit(0) if __name__ == "__main__": main() - hooks/check_completion.pyGitHub
Read the script
#!/usr/bin/env python3 """Stop hook: verify dispatcher diligence in worker-dispatching roles. Only enforced when current_role is "build" or "fixgap" — these are the roles that dispatch workers and require verifier + reviewer rounds. Other roles (scaffold, gdd, asset, verify, evaluate, accept, finalize) self-enforce via their SKILL.md Resume Check and skip this hook. Diligence rule: if workers were dispatched in this session, both verifier and reviewer must have run too (per gm-build/gm-fixgap Hard Rule 6). Anti-deadloop: if blocked BLOCK_LIMIT times in the same session, allow with a warning rather than blocking again. Only blocks the main agent (the gm-* skill), not subagents. """ import json import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from metrics import ( record_event, read_current_events, EventType, state, event_has_role, get_current_role, WORKER_DISPATCH_ROLES, ) BLOCK_LIMIT = 5 LIFECYCLE_EVENTS = (EventType.SUBAGENT_START, EventType.SUBAGENT_STOP) def check_diligence(events: list[dict], require_reviewer: bool = True) -> list[str]: """Check that workers had verifiers (and reviewers) dispatched.""" if not events: return [] worker_count = 0 verifier_seen = False reviewer_seen = False for e in events: ev = e.get("event") if ev == EventType.SUBAGENT_START and event_has_role(e, "worker"): worker_count += 1 if ev in LIFECYCLE_EVENTS: if not verifier_seen and event_has_role(e, "verifier"): verifier_seen = True if not reviewer_seen and event_has_role(e, "reviewer"): reviewer_seen = True if worker_count == 0: return [] issues = [] n = worker_count if not verifier_seen: issues.append( f"Dispatched {n} workers but 0 verifiers. " "Dispatch a verifier (subagent_type: 'verifier') to confirm " "code/tests are real and the build passes." ) if require_reviewer and not reviewer_seen: issues.append( f"Dispatched {n} workers but 0 reviewers. " "Dispatch a reviewer (subagent_type: 'reviewer') to check " "code quality and ECS patterns." ) return issues def main(): try: data = json.load(sys.stdin) except (json.JSONDecodeError, EOFError): sys.exit(0) if data.get("agent_id", ""): sys.exit(0) role = get_current_role() if role not in WORKER_DISPATCH_ROLES: record_event(EventType.GATE_CHECK, gate="completion", result="skip", role=role) sys.exit(0) block_count = state.get("stop_block_count", 0) if block_count >= BLOCK_LIMIT: record_event(EventType.GATE_CHECK, gate="completion", result="force_allow", role=role, reason=f"Blocked {block_count} times, allowing to prevent deadloop") warning = ( f"Force-allowing completion after {block_count} failed attempts. " "You MUST tell the user that diligence checks did not fully pass. " "List the unresolved issues so the user can decide whether to " "re-run /gm-build or accept as-is." ) print(json.dumps({"decision": "allow", "reason": warning}), file=sys.stderr) sys.exit(0) events = read_current_events() issues = check_diligence(events, require_reviewer=True) if issues: state.increment("stop_block_count") record_event(EventType.GATE_CHECK, gate="completion", result="fail", role=role, issues=issues[:5]) reason = ( f"Cannot finish '{role}' role — diligence issues:\n" + "\n".join(f" - {line}" for line in issues) ) print(json.dumps({"decision": "block", "reason": reason})) sys.exit(0) record_event(EventType.GATE_CHECK, gate="completion", result="pass", role=role) if __name__ == "__main__": main() - hooks/check_file_permissions.pyGitHub
Read the script
#!/usr/bin/env python3 """PreToolUse hook: enforce file write permissions per pipeline role. Reads .godotmaker/current_role and applies the role's write rules. See the gm-*/SKILL.md files for the canonical per-role rules; this hook enforces them. When no role is set, no /gm-* pipeline role is active, so regular coding-agent conversations are allowed to write normally. """ import json import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from metrics import record_event, EventType, get_current_role, WORKER_DISPATCH_ROLES GAME_CODE_EXTENSIONS = {".gd", ".tscn", ".tres"} # Project-root planning artifacts — subagents may NOT modify these unless # their agent_type is in PLANNING_WRITER_AGENT_TYPES. Includes the four # 1c decomposer outputs (PLAN/STRUCTURE/STYLE/ASSETS + SCENES/TOC), the # tag-iteration roadmap (ROADMAP.md, owned by /gm-gdd), and GAP.md # (owned by /gm-fixgap's lead, not subagents). PLANNING_DOCS = {"plan.md", "structure.md", "style.md", "assets.md", "gap.md", "scenes.md", "toc.md", "roadmap.md"} # project.godot is the engine config and changes the whole game. Subagents # may not edit it unless their agent_type is in PLANNING_WRITER_AGENT_TYPES. PROJECT_GODOT = "project.godot" E2E_DIR_PREFIX = "e2e/" ASSETS_DIR_PREFIX = "assets/" REFERENCES_DIR_PREFIX = "references/" GODOTMAKER_DIR = ".godotmaker/" # Subagent types whose entire purpose is writing planning docs — exempt # from the general subagent block on PLANNING_DOCS and PROJECT_GODOT. PLANNING_WRITER_AGENT_TYPES = {"decomposer"} # Per-role narrow write allow-lists under .godotmaker/. Each role needs # current_role + stage.jsonl for bookkeeping; evaluate / verify also write # their structured verdict; rescue is diagnostic-only (chat output only), # so it gets ONLY the bookkeeping pair — anything else attempted is a SKILL # violation worth blocking. EVAL_ALLOWED_GM_FILES = {".godotmaker/evaluation.json", ".godotmaker/stage.jsonl", ".godotmaker/current_role"} VERIFY_ALLOWED_GM_FILES = {".godotmaker/stage.jsonl", ".godotmaker/current_role", ".godotmaker/verify_report.json"} RESCUE_ALLOWED_GM_FILES = {".godotmaker/stage.jsonl", ".godotmaker/current_role"} def _is_e2e_path(path_lower: str) -> bool: return path_lower.startswith(E2E_DIR_PREFIX) or f"/{E2E_DIR_PREFIX}" in path_lower def _is_assets_path(path_lower: str) -> bool: return path_lower.startswith(ASSETS_DIR_PREFIX) or f"/{ASSETS_DIR_PREFIX}" in path_lower def _is_references_path(path_lower: str) -> bool: return path_lower.startswith(REFERENCES_DIR_PREFIX) or f"/{REFERENCES_DIR_PREFIX}" in path_lower def _is_godotmaker_path(path_lower: str) -> bool: return path_lower.startswith(GODOTMAKER_DIR) or f"/{GODOTMAKER_DIR}" in path_lower def _is_asset_generation_path(path_lower: str) -> bool: return ( path_lower.startswith(".godotmaker/asset-generation/") or "/.godotmaker/asset-generation/" in path_lower ) def _matches_allowed_gm(path_lower: str, allowed: set[str]) -> bool: """True if path ends with one of the allowed `.godotmaker/<file>` entries.""" return any(path_lower.endswith(p) for p in allowed) def _is_project_root_assets_md(path_lower: str) -> bool: """True iff path_lower resolves to the project-root ASSETS.md. The hook runs with cwd = project root, so abspath() of a relative input is anchored to that root. Accepts both bare `"assets.md"` and any absolute path that resolves to `<cwd>/ASSETS.md`. Subdirectory variants (`subdir/ASSETS.md`) and absolute paths to a different project's ASSETS.md are rejected — the asset role's contract and the deny message in `_check_main` are explicitly project-root only. Uses realpath (not abspath) because macOS routes /var/folders/... through a /private/var/folders/... symlink — abspath leaves the input untouched but cwd-derived paths often arrive already resolved, so the two sides drift and a legitimately-rooted ASSETS.md gets rejected. realpath collapses symlinks on both sides identically. """ if path_lower == "assets.md": return True abs_input = os.path.realpath(path_lower).replace("\\", "/").lower() abs_root = os.path.realpath("assets.md").replace("\\", "/").lower() return abs_input == abs_root def _block(reason: str, file_name: str, agent_id: str = "") -> None: record_event(EventType.HOOK_BLOCK, hook="check_file_permissions", reason=reason, file=file_name, agent_id=agent_id or "main") print(json.dumps({"hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason, }})) sys.exit(0) def _check_main(role: str, path_lower: str, file_name: str, ext: str) -> None: """Apply main-agent rules for the active role. Calls _block on violation.""" is_e2e = _is_e2e_path(path_lower) is_code = ext in GAME_CODE_EXTENSIONS is_godotmaker = _is_godotmaker_path(path_lower) is_assets = _is_assets_path(path_lower) if role == "evaluate": if is_e2e or _matches_allowed_gm(path_lower, EVAL_ALLOWED_GM_FILES): return _block(f"Evaluator can only write e2e/, .godotmaker/evaluation.json, " f".godotmaker/stage.jsonl, or .godotmaker/current_role " f"(attempted: {file_name}).", file_name) if role == "verify": if _matches_allowed_gm(path_lower, VERIFY_ALLOWED_GM_FILES): return _block(f"Verify is read-only except .godotmaker/stage.jsonl, " f".godotmaker/current_role, and .godotmaker/verify_report.json " f"(attempted: {file_name}).", file_name) if role == "rescue": if _matches_allowed_gm(path_lower, RESCUE_ALLOWED_GM_FILES): return _block(f"Rescue is diagnostic-o - hooks/check_stage_prerequisites.pyGitHub
Read the script
#!/usr/bin/env python3 """PreToolUse hook (Agent tool): verify role prerequisites before worker dispatch. Only enforces for roles that drive worker orchestration: - build → requires gdd completed + scaffold artifacts present - fixgap → requires evaluate completed + evaluation.json present Scaffold artifacts are checked on disk because gm-finalize truncates stage.jsonl at every tag boundary, so the scaffold event is no longer in stage.jsonl after the first finalize. Other dispatching roles (asset → analyst) self-validate via their SKILL.md Resume Check; their preconditions don't match this hook's stage-schema model. """ import json import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from metrics import ( record_event, EventType, get_current_role, get_completed_roles, load_stage_schemas, WORKER_DISPATCH_ROLES, ) PREREQ_ROLE = { "build": "gdd", "fixgap": "evaluate", } # Sanity check: PREREQ_ROLE must cover exactly the worker-dispatching roles. # Use an explicit raise instead of assert so the check survives `python -O`. if frozenset(PREREQ_ROLE) != WORKER_DISPATCH_ROLES: raise RuntimeError( f"PREREQ_ROLE keys {sorted(PREREQ_ROLE)} must equal " f"WORKER_DISPATCH_ROLES {sorted(WORKER_DISPATCH_ROLES)}" ) # Roles that need scaffold artifacts on disk (lifetime-once, not in stage.jsonl # after the first tag's finalize truncates the log). SCAFFOLD_REQUIRED = frozenset({"build"}) def main(): try: data = json.load(sys.stdin) except (json.JSONDecodeError, EOFError): sys.exit(0) if data.get("tool_name") != "Agent": sys.exit(0) # Only check main agent (the gm-* skill orchestrating dispatch) if data.get("agent_id", ""): sys.exit(0) role = get_current_role() prereq = PREREQ_ROLE.get(role) if not prereq: sys.exit(0) # No worker-dispatch role active, nothing to enforce completed = get_completed_roles() issues = [] if role in SCAFFOLD_REQUIRED and not os.path.isfile("project.godot"): issues.append("project.godot not found — run /gm-scaffold first") if prereq not in completed: issues.append(f"Role '{prereq}' has not completed yet — run /gm-{prereq} first") schemas = load_stage_schemas() if schemas: prereq_schema = schemas.get(prereq, {}) # Inline existence check here (not validate_schema_files) to keep the # role-aware "{prereq} output missing: X" message style. for filepath in prereq_schema.get("files", []): if not os.path.exists(filepath): issues.append(f"{prereq} output missing: {filepath}") if issues: reason = ( f"Cannot dispatch worker — '{role}' role prerequisites missing:\n" + "\n".join(f" - {m}" for m in issues) ) record_event(EventType.HOOK_BLOCK, hook="check_stage_prerequisites", role=role, missing=issues) print(json.dumps({"hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason, }})) sys.exit(0) record_event(EventType.HOOK_ALLOW, hook="check_stage_prerequisites", role=role) if __name__ == "__main__": main() - hooks/check_worker_report.pyGitHub
- hooks/log_agent_tool.pyGitHub
- hooks/log_compaction.pyGitHub
- hooks/log_subagent.pyGitHub
- hooks/on_subagent_stop.pyGitHub
- hooks/playability_contract.pyGitHub
- hooks/session_start.pyGitHub
- hooks/stage_reminder.pyGitHub
All 14 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withgodotmaker
Autonomous text-to-game pipeline for Godot, powered by Claude Code,Codex,Opencode
Get the whole plugin
Stats
511
Stars
44
Forks
Active
Maintenance
Python
Language
1d ago
Last commit
3mo ago
Created
Repo: RandallLiuXin/GodotMaker

