Skip to content

gate.sh

.claude/hooks/gate.sh

PreToolUse

Generic hard-gate hook — holds state-changing actions until a gated doc's YAML frontmatter says its exit gate is open. The engine has no skill-specific knowledge. A skill opts in by shipping a plain KEY=value gate.conf in its OWN directory — .claude/skills/<skill>/gate.conf — so the config lives and dies with the skill that owns it (delete the skill, the gate goes too): DOC_GLOB glob of the docs this gate watches (frontmatter = state) EXIT_FIELD frontmatter gates.* field that, once true, opens everything ALLOW_PREFIX path prefix that stays writable while gated ADVANCE_TOKEN exact UPPERCASE word that opens the next closed gate EXIT_TOKEN exact UPPERCASE word that opens EXIT_FIELD BYPASS_VAR env var that disables this gate (e.g. GATE_BYPASS) REQUIRE_<stage> (optional) comma-list of artifacts (beside the doc) that must exist before that stage's exit token flips. Stage matched CASE-INSENSITIVELY against the lowercase stage: value. LEDGER_DIR (optional) dir OUTSIDE ALLOW_PREFIX for hook-written marks FANOUT_SUBAGENT (optional) subagent whose Task dispatch is held until the phase's lens mark is in the ledger (forces the EFFORT prompt) PHASE_<stage> (optional) maps a stage to its phase, for the ledger marks Two events, one script (dispatched on hook_event_name): UserPromptSubmit — watches the maintainer's message for ADVANCE_TOKEN / EXIT_TOKEN and flips the matching frontmatter gates.* flag in the active doc. Refuses the flip (exit 2) if any REQUIRE_<stage> artifact is missing. Also records typed LENS … / EXTERNAL … picks into the hook-only ledger. PreToolUse — while an active doc's EXIT_FIELD is still closed, holds code-writes outside ALLOW_PREFIX, build-* dispatch, and the FANOUT_SUBAGENT dispatch until its lens mark exists (exit 2 = deny). Issue creation passes. A doc is "active" when its leading frontmatter has stage: present (and neither done nor trashed) AND session: matching this session's CLAUDE_CODE_SESSION_ID — so concurrent sessions never gate each other. FAILS OPEN: no session id, no gate.conf, no owned doc → allow. Only the leading YAML block is read, so body prose mentioning stage: is never mistaken for live state. ⚠ Web/mobile firing is UNVERIFIED (feasibility spike pending). Consumers must work behaviourally without this hook; it is only the mechanical enforcement layer. Registered in settings.json (UserPromptSubmit + PreToolUse) alongside the existing hooks.

Source

bash
#!/usr/bin/env bash
#
# Generic hard-gate hook — holds state-changing actions until a gated doc's
# YAML frontmatter says its exit gate is open.
#
# The engine has no skill-specific knowledge. A skill opts in by shipping a
# plain KEY=value `gate.conf` in its OWN directory —
# .claude/skills/<skill>/gate.conf — so the config lives and dies with the
# skill that owns it (delete the skill, the gate goes too):
#
#   DOC_GLOB        glob of the docs this gate watches (frontmatter = state)
#   EXIT_FIELD      frontmatter gates.* field that, once true, opens everything
#   ALLOW_PREFIX    path prefix that stays writable while gated
#   ADVANCE_TOKEN   exact UPPERCASE word that opens the next closed gate
#   EXIT_TOKEN      exact UPPERCASE word that opens EXIT_FIELD
#   BYPASS_VAR      env var that disables this gate (e.g. GATE_BYPASS)
#   REQUIRE_<stage> (optional) comma-list of artifacts (beside the doc) that must
#                   exist before that stage's exit token flips. Stage matched
#                   CASE-INSENSITIVELY against the lowercase `stage:` value.
#   LEDGER_DIR      (optional) dir OUTSIDE ALLOW_PREFIX for hook-written marks
#   FANOUT_SUBAGENT (optional) subagent whose Task dispatch is held until the
#                   phase's lens mark is in the ledger (forces the EFFORT prompt)
#   PHASE_<stage>   (optional) maps a stage to its phase, for the ledger marks
#
# Two events, one script (dispatched on hook_event_name):
#
#   UserPromptSubmit — watches the maintainer's message for ADVANCE_TOKEN /
#     EXIT_TOKEN and flips the matching frontmatter gates.* flag in the active
#     doc. Refuses the flip (exit 2) if any REQUIRE_<stage> artifact is missing.
#     Also records typed `LENS …` / `EXTERNAL …` picks into the hook-only ledger.
#
#   PreToolUse — while an active doc's EXIT_FIELD is still closed, holds
#     code-writes outside ALLOW_PREFIX, build-* dispatch, and the FANOUT_SUBAGENT
#     dispatch until its lens mark exists (exit 2 = deny). Issue creation passes.
#
# A doc is "active" when its leading frontmatter has `stage:` present (and
# neither done nor trashed) AND `session:` matching this session's
# CLAUDE_CODE_SESSION_ID — so concurrent sessions never gate each other.
# FAILS OPEN: no session id, no gate.conf, no owned doc → allow. Only the
# leading YAML block is read, so body prose mentioning `stage:` is never
# mistaken for live state.
#
# ⚠ Web/mobile firing is UNVERIFIED (feasibility spike pending). Consumers
# must work behaviourally without this hook; it is only the mechanical
# enforcement layer. Registered in settings.json (UserPromptSubmit +
# PreToolUse) alongside the existing hooks.

set -euo pipefail

cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0

sid="${CLAUDE_CODE_SESSION_ID:-}"
[ -n "$sid" ] || exit 0          # can't scope without a session id → allow

shopt -s nullglob
confs=(.claude/skills/*/gate.conf)
[ ${#confs[@]} -gt 0 ] || exit 0  # no gated skills → allow

input="$(cat)"
event="$(jq -r '.hook_event_name // empty' <<<"$input")"

# The leading YAML frontmatter only (between the first --- pair).
frontmatter() { sed -n '1{/^---[[:space:]]*$/!q}; 1d; /^---[[:space:]]*$/q; p' "$1"; }

conf_get() { sed -n "s/^$2=//p" "$1" | head -1; }

# This session's active doc for a glob: stage present, not done/trashed,
# session matches; newest `updated` wins.
active_doc() {
  local glob="$1" newest="" newest_ts="" doc fm ts dsid
  for doc in $glob; do
    [ -f "$doc" ] || continue
    fm="$(frontmatter "$doc")"
    grep -qE '^stage:[[:space:]]*' <<<"$fm" || continue
    grep -qE '^stage:[[:space:]]*(done|trashed)[[:space:]]*$' <<<"$fm" && continue
    dsid="$(sed -n 's/^session:[[:space:]]*//p' <<<"$fm" | head -1)"
    [ "$dsid" = "$sid" ] || continue
    ts="$(sed -n 's/^updated:[[:space:]]*//p' <<<"$fm" | head -1)"
    if [ -z "$newest" ] || [[ "$ts" > "$newest_ts" ]]; then
      newest="$doc"; newest_ts="$ts"
    fi
  done
  printf '%s' "$newest"
}

set_gate() {
  local doc="$1" key="$2"
  sed -i -E "s/^([[:space:]]*${key}:[[:space:]]*)false[[:space:]]*$/\\1true/" "$doc"
}

# True when the conf's BYPASS_VAR env var is set to 1.
gate_bypassed() {
  local bypass_var; bypass_var="$(conf_get "$1" BYPASS_VAR)"
  [ -n "$bypass_var" ] && [ "${!bypass_var:-0}" = "1" ]
}

# Echoes a held-message and returns 0 (block) when any artifact the doc's current
# stage requires (REQUIRE_<stage>, a comma-list, beside the doc) is missing. The
# stage is matched CASE-INSENSITIVELY (real docs use lowercase `stage:`). Returns 1
# (allow) when unconfigured or every required artifact is present.
require_missing() {
  local doc="$1" conf="$2" stage list dir f artifact skill
  stage="$(frontmatter "$doc" | sed -n 's/^stage:[[:space:]]*//p' | head -1 | sed -E 's/[[:space:]]+$//' | tr '[:upper:]' '[:lower:]')"
  [ -n "$stage" ] || return 1
  list="$(conf_get "$conf" "REQUIRE_${stage}")"
  [ -n "$list" ] || return 1
  dir="$(dirname "$doc")"
  while IFS= read -r f; do
    f="$(printf '%s' "$f" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
    [ -n "$f" ] || continue
    artifact="$dir/$f"
    if [ ! -f "$artifact" ]; then
      skill="$(basename "$(dirname "$conf")")"
      echo "Held by the ${skill} gate: ${stage} needs ${f} before advancing — produce/export it first." >&2
      return 0
    fi
  done < <(printf '%s\n' "$list" | tr ',' '\n')
  return 1
}

# --- Fan-out ledger -----------------------------------------------------------
# The ledger holds maintainer-typed marks (e.g. lens picks). It lives OUTSIDE any
# ALLOW_PREFIX, so the agent's Write/Edit there is already denied — only this hook
# writes it, which is what makes a mark unforgeable. One flat key=value file per
# active doc, keyed by the doc's own directory name.
ledger_file() {  # $1=doc  $2=LEDGER_DIR
  printf '%s/%s.marks' "$2" "$(basename "$(dirname "$1")")"
}
# Phase for the doc's current stage (PHASE_<stage> in conf), case-insensitive.
phase_for() {  # $1=conf  $2=doc
  local stage
  stage="$(frontmatter "$2" | sed -n 's/^stage:[[:space:]]*//p' | head -1 | sed -E 's/[[:space:]]+$//' | tr '[:upper:]' '[:lower:]')"
  [ -n "$stage" ] && conf_get "$1" "PHASE_${stage}"
}
mark_present() { [ -f "$1" ] && grep -qE "^$2=" "$1"; }  # $1=file  $2=key

case "$event" in
  UserPromptSubmit)
    msg="$(jq -r '.prompt // empty' <<<"$input")"
    for conf in "${confs[@]}"; do
      glob="$(conf_get "$conf" DOC_GLOB)";        [ -n "$glob" ] || continue
      doc="$(active_doc "$glob")";                [ -n "$doc" ] || continue
      exit_field="$(conf_get "$conf" EXIT_FIELD)"
      exit_tok="$(conf_get "$conf" EXIT_TOKEN)"
      adv_tok="$(conf_get "$conf" ADVANCE_TOKEN)"
      if [ -n "$exit_tok" ] && grep -qwE "$exit_tok" <<<"$msg"; then
        if ! gate_bypassed "$conf" && require_missing "$doc" "$conf"; then exit 2; fi
        [ -n "$exit_field" ] && set_gate "$doc" "$exit_field"
      elif [ -n "$adv_tok" ] && grep -qwE "$adv_tok" <<<"$msg"; then
        if ! gate_bypassed "$conf" && require_missing "$doc" "$conf"; then exit 2; fi
        key="$(grep -E '^[[:space:]]*[a-z_]+_to_[a-z_]+:[[:space:]]*false' "$doc" | head -1 | sed -E 's/^[[:space:]]*([a-z_]+):.*/\1/')"
        [ -n "$key" ] && set_gate "$doc" "$key"
      fi

      # Record typed effort picks (LENS / EXTERNAL ...) into the hook-only ledger.
      ledger_dir="$(conf_get "$conf" LEDGER_DIR)"
      if [ -n "$ledger_dir" ]; then
        phase="$(phase_for "$conf" "$doc")"
        if [ -n "$phase" ]; then
          lf="$(ledger_file "$doc" "$ledger_dir")"
          if grep -qiE '^[[:space:]]*LENS[: ]' <<<"$msg"; then
            mkdir -p "$ledger_dir"
            printf 'lenses_%s=%s\n' "$phase" "$(sed -E 's/^[[:space:]]*[Ll][Ee][Nn][Ss][: ]+//' <<<"$msg" | head -1)" >> "$lf"
          fi
          if grep -qiE '^[[:space:]]*EXTERNAL[: ]' <<<"$msg"; then
            mkdir -p "$ledger_dir"
            printf 'external_%s=%s\n' "$phase" "$(sed -E 's/^[[:space:]]*[Ee][Xx][Tt][Ee][Rr][Nn][Aa][Ll][: ]+//' <<<"$msg" | head -1)" >> "$lf"
          fi
        fi
      fi
    done
    exit 0
    ;;

  PreToolUse)
    tool="$(jq -r '.tool_name // empty' <<<"$input")"
    target="$(jq -r '.tool_input.file_path // .tool_input.command // .tool_input.subagent_type // empty' <<<"$input")"

    for conf in "${confs[@]}"; do
      bypass_var="$(conf_get "$conf" BYPASS_VAR)"
      if [ -n "$bypass_var" ] && [ "${!bypass_var:-0}" = "1" ]; then continue; fi

      glob="$(conf_get "$conf" DOC_GLOB)";        [ -n "$glob" ] || continue
      doc="$(active_doc "$glob")";                [ -n "$doc" ] || continue

      exit_field="$(conf_get "$conf" EXIT_FIELD)"
      open="$(frontmatter "$doc" | sed -n -E "s/^[[:space:]]*${exit_field}:[[:space:]]*//p" | head -1)"
      [ "$open" = "true" ] && continue            # exit gate open → this conf allows

      allow_prefix="$(conf_get "$conf" ALLOW_PREFIX)"
      exit_tok="$(conf_get "$conf" EXIT_TOKEN)"

      deny=0
      case "$tool" in
        Write|Edit)
          rel="${target#"$PWD"/}"
          rel="${rel#./}"
          if [ -z "$allow_prefix" ] || [[ "$rel" != "$allow_prefix"* ]]; then deny=1; fi
          ;;
        Task)
          [[ "$target" == *build-* ]] && deny=1
          # Fan-out gate: hold the divergence dispatch until the phase's lens pick
          # is recorded — forces the EFFORT prompt before any diverge.
          fanout="$(conf_get "$conf" FANOUT_SUBAGENT)"
          ledger_dir="$(conf_get "$conf" LEDGER_DIR)"
          if [ "$deny" = 0 ] && [ -n "$fanout" ] && [ -n "$ledger_dir" ] && [[ "$target" == *"$fanout"* ]]; then
            phase="$(phase_for "$conf" "$doc")"
            if [ -n "$phase" ] && ! mark_present "$(ledger_file "$doc" "$ledger_dir")" "lenses_${phase}"; then
              skill="$(basename "$(dirname "$conf")")"
              echo "Held by the ${skill} gate: pick your lenses first — type LENS <your picks> (the EFFORT step) before fanning out." >&2
              exit 2
            fi
          fi
          ;;
      esac

      if [ "$deny" = 1 ]; then
        skill="$(basename "$(dirname "$conf")")"
        echo "Held by the ${skill} gate: state-changing actions open at its ${exit_tok:-exit} gate. Type ${exit_tok:-the exit token}, or set ${bypass_var:-GATE_BYPASS}=1 for unrelated work." >&2
        exit 2
      fi
    done
    exit 0
    ;;
esac

exit 0