Skip to content
Development
Hook

Hooks

What dotnet-episteme-skills runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
dotnet-episteme-skills
1214 skills13 agents3 commands3 hooks
+1
Install
> /plugin marketplace add Metalnib/dotnet-episteme-skills
> /plugin install dotnet-episteme-skills@dotnet-episteme-marketplace

Ships with dotnet-episteme-skills. Installing the plugin gets these hooks.

What fires, and when

PreToolUse

  • MatchesBash"${CLAUDE_PLUGIN_ROOT}"/hooks/git-readonly-guard.sh

SessionStart

Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.

  • Matchesstartup"${CLAUDE_PLUGIN_ROOT}"/hooks/design-state-reload.sh
  • Matchescompact"${CLAUDE_PLUGIN_ROOT}"/hooks/design-state-reload.sh
  • Matchesclear"${CLAUDE_PLUGIN_ROOT}"/hooks/design-state-reload.sh
  • Matchesresume"${CLAUDE_PLUGIN_ROOT}"/hooks/design-state-reload.sh

PreCompact

  • Matchesauto
  • Matchesmanual
Read hooks/hooks.json

Where it lives

  • hooks/design-state-reload.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # SessionStart hook (matchers: startup, compact, clear, resume) for the dotnet-refactor pipeline.
    # On a fresh session, and after compaction/clear/resume, re-inject the active DESIGN state file
    # so the loop resumes from the artifact instead of from lost conversation history.
    set -euo pipefail
    
    dir="${CLAUDE_PROJECT_DIR:-$PWD}/.episteme"
    
    # Newest DESIGN-*.md whose frontmatter is not `status: done`. The status line
    # must be read from the YAML frontmatter only - the Decision log body carries
    # status/phase entries too, so a whole-file grep would skip an active loop.
    # The .episteme/ convention is shared with commands/dotnet-refactor.md.
    state_file=""
    for f in "$dir"/DESIGN-*.md; do
      [ -e "$f" ] || continue
      # Frontmatter = lines between the line-1 `---` fence and the next `---`.
      frontmatter="$(awk 'NR==1 && $0!="---"{exit} $0=="---"{n++; next} n==1{print} n>=2{exit}' "$f" 2>/dev/null)"
      printf '%s\n' "$frontmatter" | grep -Eq '^status:[[:space:]]*done[[:space:]]*$' && continue
      if [ -z "$state_file" ] || [ "$f" -nt "$state_file" ]; then
        state_file="$f"
      fi
    done
    
    [ -z "$state_file" ] && exit 0
    
    # Cap what we inject; the file itself stays the source of truth.
    content=$(head -c 16384 "$state_file")
    
    python3 - "$state_file" <<'PY' "$content"
    import json, sys
    path, content = sys.argv[1], sys.argv[2]
    print(json.dumps({
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": (
                f"An active /dotnet-refactor design loop was found at {path}. "
                f"Resume from the phase and status its frontmatter records; the file is the source of truth, not prior conversation.\n\n"
                f"{content}"
            ),
        }
    }))
    PY
    
  • hooks/git-readonly-guard.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # PreToolUse guard: this plugin's worker agents (review, refactor and qa lanes)
    # may only run read-only git and synopsis commands. Other agents and the main
    # session pass through untouched.
    set -euo pipefail
    
    INPUT="$(cat)"
    
    # Subagent calls carry agent_type on both Claude Code and Codex (>=0.145);
    # main-session calls don't - skip those without spawning python3.
    case "$INPUT" in
      *'"agent_type"'*) ;;
      *) exit 0 ;;
    esac
    
    PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
    python3 - "$INPUT" "$PLUGIN_ROOT" <<'PY'
    import json, os, re, shlex, sys
    
    data = json.loads(sys.argv[1])
    agent = data.get("agent_type") or ""
    # Exact lane sets (Claude namespaced, Codex flat) - a user's unrelated
    # "review-*"/"qa-*" role must not be restricted.
    GROUPS = {
        "review": {"correctness", "performance", "security-observability",
                   "data-messaging", "generalist", "maintainer"},
        "refactor": {"cartographer", "tracer", "conformance-auditor", "surveyor"},
        "qa": {"acceptance", "reuse-design", "dead-code"},
    }
    matched_group = next((group for group, lanes in GROUPS.items()
                          for p in (f"dotnet-episteme-skills:{group}:", f"{group}-")
                          if agent.startswith(p) and agent[len(p):] in lanes), None)
    if matched_group is None:
        # Not one of this plugin's worker agents (main session, other agents,
        # skills): exit 0 = no opinion, the normal permission flow applies.
        sys.exit(0)
    
    cmd = (data.get("tool_input") or {}).get("command") or ""
    # No chaining/redirection/substitution - read-only commands need none of it.
    if re.search(r"[;&|<>`$]", cmd) or "\n" in cmd:
        print("Blocked for plugin worker agents: shell operators are not allowed.", file=sys.stderr)
        sys.exit(2)
    # Every worker group gets the read-only search/list tools on top of git and
    # synopsis - fast tools (rg, fd) plus the GNU fallbacks, and `command -v`/`which`
    # to probe which is installed. Review and qa lanes used to be denied these, which
    # only cost them a run of failed calls before they fell back to the Grep tool:
    # the native Read/Grep/Glob tools are not seen by this hook at all, so denying the
    # shell equivalents bought no confinement it did not already lack.
    #
    # The shell-operator block above stops chaining/redirection; but several of
    # these tools can execute or write through their OWN flags with no operator at
    # all (rg --pre runs a command per file, tree -o writes a file, file -C
    # compiles one). Each tool's write/exec flags are denied explicitly. Short
    # flags cluster (-Cm is -C -m, -zi is -z -i), so the short-flag half of each
    # pattern matches the letter anywhere in a leading cluster; the letter sets are
    # per-tool so a safe flag on one tool (rg -x = --line-regexp) is not blocked
    # because it is dangerous on another (fd -x = --exec). `-o` is per-tool too:
    # rg/grep use it for --only-matching (safe), tree uses it to write (denied).
    DANGER_FLAGS = {
        "rg":   r"(^|\s)(--pre|--pre-glob|--hostname-bin|--search-zip)(\s|=|$)|(^|\s)-[A-Za-z]*z",
        "tree": r"(^|\s)(-o|--output)",
        "file": r"(^|\s)(--compile)(\s|=|$)|(^|\s)-[A-Za-z]*C",
        "find": r"(^|\s)(-delete|-exec\w*|-ok\w*|-fprint\w*|-fls)(\s|$)",
        "fd":   r"(^|\s)(--exec|--exec-batch)(\s|=|$)|(^|\s)-[A-Za-z]*[xX]",
    }
    if matched_group in ("refactor", "review", "qa"):
        if re.match(r"^\s*(command\s+-v|which)\s", cmd):
            sys.exit(0)
        tm = re.match(r"^\s*(rg|fd|grep|ls|eza|cat|head|tail|wc|tree|file|stat|find)\b", cmd)
        if tm:
            danger = DANGER_FLAGS.get(tm.group(1))
            if danger and re.search(danger, cmd):
                print(f"Blocked for plugin worker agents: {tm.group(1)} write/exec flags are not allowed.", file=sys.stderr)
                sys.exit(2)
            # Confine reads to the project. An absolute path is allowed when it
            # resolves inside the project directory - the same rule `git -C` already
            # uses below, and what a worker naturally writes when it knows the repo
            # root. Parent-directory escapes stay banned outright. NOTE this governs
            # the SHELL only - the native Read/Grep/Glob tools are not seen by this
            # hook (see docs/reviewer-restrictions.md), so on Claude Code this is
            # defence in depth, not an absolute read sandbox.
            try:
                tokens = shlex.split(cmd)
            except ValueError:
                print("Blocked for plugin worker agents: could not parse the command safely.", file=sys.stderr)
                sys.exit(2)
            # Codex has no CLAUDE_PROJECT_DIR; its payload carries the turn cwd instead.
            proj = os.environ.get("CLAUDE_PROJECT_DIR") or data.get("cwd")
    
            def inside_project(path):
                if not proj:
                    return False
                real = os.path.realpath(os.path.expanduser(path))
                proj_real = os.path.realpath(proj)
                return real == proj_real or real.startswith(proj_real + os.sep)
    
            for tok in tokens[1:]:
                if tok.startswith("-"):
                    fv = re.search(r"=([~/].*)$", tok)
                    if fv and not inside_project(fv.group(1)):
                        print("Blocked for plugin worker agents: that flag value points outside the project directory.", file=sys.stderr)
                        sys.exit(2)
                    continue
                if tok.startswith("/") or tok.startswith("~"):
                    if not inside_project(tok):
                        print("Blocked for plugin worker agents: that path is outside the project directory; search within the project or use the Read/Grep tools.", file=sys.stderr)
                        sys.exit(2)
                    continue
                if tok == ".." or tok.startswith("../") or "/../" in tok or tok.endswith("/.."):
                    print("Blocked for plugin worker agents: parent-directory escapes are not allowed.", file=sys.stderr)
                    sys.exit(2)
            sys.exit(0)
    # --output as a whole flag only: git's read-only --output-indicator-{new,old,context}
    # write nothing and must not be bl

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 withdotnet-episteme-skills

DotNet Episteme Skills - a curated, manual-first .NET AI skills library rooted in systematic knowledge (episteme) and shaped by disciplined craft (techne), designed for engineers who prioritise precision over hype.

Get the whole plugin