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/. A developer has created an anti-deferral hook script that scans the last assistant message in a transcript for banned deferral phrases and blocks responses containing them. The script, designed to live in a project's `.claude/hooks/` directory with supporting `.txt` files in a `lib` subfolder, catches chat-response failure modes where an assistant types phrases like "we can revisit this" without filing an operational change. The hook skips lines inside fenced code blocks and honors a dismiss sentinel for meta-discussion of the rule itself. | /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