# Anti-deferral hook.  IMPORTANT: The .sh file in expects to live in (project dir)/.claude/hooks/ and expects the .txt files to live in the lib subdir at (project dir)/.claude/hooks/lib/.

> Source: <https://gist.github.com/michael-jennings/7d31353941cc90a2e7d7cb251a8afb0e>
> Published: 2026-05-16 05:53:31+00:00

|
#!/bin/bash |
|
# |
|
# IMPORTANT: |
|
# * This must be copied to (project dir)/.claude/hooks/ |
|
# * The txt files must be copied to the lib subfolder: (project dir)/.claude/hooks/lib/ |
|
# * Customize the feedback claude gets if deferral phrases are detected, by editing the |
|
# echo section at the bottom. |
|
# |
|
# response-deferral-phrase-gate.sh |
|
# |
|
# Stop hook -- scans the last assistant message in the transcript for banned |
|
# deferral phrases and exits 2 (blocking) if any are found. |
|
# |
|
# Catches the chat-response failure mode: typing "we can revisit this" or |
|
# "or accept the slight gap" in a reply without filing a real opsx change. |
|
# |
|
# Scope: |
|
# - Scans only the LAST assistant message |
|
# - Skips lines inside fenced code blocks (so quoting the rule is fine) |
|
# - Honors a dismiss sentinel: `<!-- deferral-meta -->` anywhere in the |
|
# response, for meta-discussion of the rule itself |
|
|
|
set -uo pipefail |
|
|
|
INPUT=$(cat) |
|
TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null || echo "") |
|
|
|
if [[ -z "$TRANSCRIPT" ]] || [[ ! -f "$TRANSCRIPT" ]]; then exit 0; fi |
|
|
|
REPO_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" |
|
|
|
# Load banned phrases + the "pre-existing" dismissal regex from data files |
|
# shared with the archive-gate and write-gate hooks. |
|
declare -a BANNED_PHRASES=() |
|
while IFS= read -r LINE; do |
|
LINE="${LINE%%#*}" |
|
LINE="${LINE#"${LINE%%[![:space:]]*}"}" |
|
LINE="${LINE%"${LINE##*[![:space:]]}"}" |
|
[[ -z "$LINE" ]] && continue |
|
BANNED_PHRASES+=("$LINE") |
|
done < "$REPO_ROOT/.claude/hooks/lib/deferral-banned-phrases.txt" |
|
PRE_EXISTING_DISMISSAL_REGEX="" |
|
while IFS= read -r LINE; do |
|
LINE="${LINE%%#*}" |
|
LINE="${LINE#"${LINE%%[![:space:]]*}"}" |
|
LINE="${LINE%"${LINE##*[![:space:]]}"}" |
|
[[ -z "$LINE" ]] && continue |
|
PRE_EXISTING_DISMISSAL_REGEX="$LINE" |
|
break |
|
done < "$REPO_ROOT/.claude/hooks/lib/deferral-pre-existing-regex.txt" |
|
|
|
# Find the last assistant message JSONL line in the transcript. |
|
LAST_ASSISTANT_LINE=$(tac "$TRANSCRIPT" 2>/dev/null | while IFS= read -r LINE; do |
|
ROLE=$(echo "$LINE" | jq -r '(.message.role // .role) // empty' 2>/dev/null || echo "") |
|
if [[ "$ROLE" == "assistant" ]]; then |
|
echo "$LINE" |
|
break |
|
fi |
|
done) |
|
|
|
if [[ -z "$LAST_ASSISTANT_LINE" ]]; then exit 0; fi |
|
|
|
# Extract concatenated text content from the message (skip tool_use, thinking, etc). |
|
TEXT=$(echo "$LAST_ASSISTANT_LINE" | jq -r ' |
|
(.message.content // .content // []) as $c |
|
| if ($c | type) == "array" then |
|
$c | map(select(type == "object" and .type == "text") | .text) | join("\n") |
|
elif ($c | type) == "string" then |
|
$c |
|
else |
|
"" |
|
end |
|
' 2>/dev/null || echo "") |
|
|
|
if [[ -z "$TEXT" ]]; then exit 0; fi |
|
|
|
# Dismiss sentinel for meta-discussion of the rule itself. |
|
if echo "$TEXT" | grep -qF '<!-- deferral-meta -->'; then exit 0; fi |
|
|
|
# Strip fenced code blocks so quoting the rule in ``` ... ``` doesn't trip. |
|
TEXT_NO_FENCES=$(echo "$TEXT" | awk ' |
|
/^[[:space:]]*```/ { in_block = !in_block; next } |
|
!in_block { print } |
|
') |
|
|
|
# Collect hits as: line:phrase:text |
|
declare -a HITS=() |
|
|
|
for PHRASE in "${BANNED_PHRASES[@]}"; do |
|
while IFS= read -r MATCH; do |
|
[[ -z "$MATCH" ]] && continue |
|
HITS+=("L$MATCH :: phrase=\"$PHRASE\"") |
|
done < <(echo "$TEXT_NO_FENCES" | grep -inF "$PHRASE" 2>/dev/null) |
|
done |
|
|
|
# "pre-existing" dismissal pattern |
|
while IFS= read -r MATCH; do |
|
[[ -z "$MATCH" ]] && continue |
|
if echo "$MATCH" | grep -iqE "$PRE_EXISTING_DISMISSAL_REGEX"; then |
|
HITS+=("L$MATCH :: phrase=\"pre-existing (dismissal)\"") |
|
fi |
|
done < <(echo "$TEXT_NO_FENCES" | grep -inF "pre-existing" 2>/dev/null) |
|
|
|
if [[ ${#HITS[@]} -eq 0 ]]; then exit 0; fi |
|
|
|
{ |
|
echo "[response-deferral-phrase-gate] BLOCK: banned deferral phrase(s) in your last response." |
|
echo "" |
|
echo "Hits (line numbers relative to your last message, code blocks excluded):" |
|
for HIT in "${HITS[@]}"; do |
|
echo " $HIT" |
|
done |
|
echo "" |
|
echo "Per .claude/rules/no-ephemeral-deferrals.md, every deferral-shaped" |
|
echo "statement must resolve via case 1, case 2, or case 3:" |
|
echo "" |
|
echo " case 1: revise an existing pending change + /opsx:apply this turn" |
|
echo " case 2: /opsx:propose <name> -> apply -> sync -> archive, this turn" |
|
echo " case 3: /opsx:new <name> proposal-only + loud ## DEFERRED: callout" |
|
echo "" |
|
echo "If you genuinely need to discuss the rule itself, include the sentinel" |
|
echo "<!-- deferral-meta --> in your response. The bar for using the sentinel" |
|
echo "is HIGH -- it dismisses the gate entirely for that response." |
|
echo "" |
|
echo "Otherwise: rewrite the deferral sentence to close the loop, or delete" |
|
echo "it (the concern was not real), and respond again." |
|
} >&2 |
|
|
|
exit 2 |
