Skip to content
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.

From plugin
godotmaker
51141 skills7 agents14 hooks
Install
$ npx -y skills add RandallLiuXin/GodotMaker --agent claude-code

Ships 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