Appearance
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