fix(hooks): Claude never saw hook messages — systemMessage is user-only#135
fix(hooks): Claude never saw hook messages — systemMessage is user-only#135
Conversation
Root cause: Both hookify and metacognitive-guard used `systemMessage` to communicate with Claude when blocking tools. Per Claude Code hooks spec, `systemMessage` is "shown to the user" only. Claude receives `permissionDecisionReason` (PreToolUse), `reason` (Stop/PostToolUse), `additionalContext` (UserPromptSubmit/PostToolUse/SessionStart), or stderr with exit code 2. hookify/rule_engine.py: - PreToolUse deny: systemMessage → permissionDecisionReason - PostToolUse block: systemMessage → decision/reason - UserPromptSubmit block: systemMessage → decision/reason - Warnings: systemMessage → additionalContext where Claude can see it - Removed all systemMessage usage (no audience) hookify/hook_runner.py: - Error reporting: systemMessage + exit 0 → stderr + exit 2 hookify/mtp-smart-test-filtering: - Rewrote 80-line MTP docs wall → 8-line directive message metacognitive-guard/epistemic-guard.sh: - Migrated 5 blocks from deprecated decision/reason to modern hookSpecificOutput.permissionDecision/permissionDecisionReason metacognitive-guard/integrity-check.sh: - Exit code 1 (non-blocking!) → exit code 2 (actually blocks commits) - stdout (invisible) → stderr (Claude sees it) metacognitive-guard/struggle-detector: - Split into two hooks: Stop (async, analysis + blackboard write) and new struggle-inject.sh on UserPromptSubmit (reads blackboard, injects additionalContext that Claude actually sees) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Caution Review failedFailed to post review comments 📝 WalkthroughWalkthroughRefactors hook outputs to Claude Code hooks format, improves hook runner error handling to emit stderr and exit code 2 on failures, converts struggle detector to an async Stop + UserPromptSubmit inject flow via a blackboard, and adds a five-agent council plugin (/council). Changes
Sequence Diagram(s)sequenceDiagram
participant User as User
participant Claude as Claude
participant HookRunner as Hook Runner
participant StopHook as Stop Hook (struggle-detector)
participant Blackboard as .blackboard
participant InjectHook as Inject Hook (struggle-inject)
rect rgba(200,200,255,0.5)
User->>Claude: Submit prompt
Claude->>HookRunner: Trigger UserPromptSubmit hooks
end
rect rgba(200,255,200,0.5)
HookRunner->>InjectHook: Execute struggle-inject.sh
InjectHook->>Blackboard: Read .struggle-count & .struggle-signals
InjectHook-->>HookRunner: Return hookSpecificOutput.additionalContext (if threshold)
HookRunner->>Claude: Provide additionalContext for response
end
rect rgba(255,200,200,0.5)
Note over StopHook,Blackboard: Asynchronous prior flow
StopHook->>Blackboard: Write struggle signals & increment count
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes 🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary of ChangesHello @ANcpLua, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly enhances the reliability and effectiveness of Claude's interaction with various hooks. The core problem addressed was Claude's inability to perceive messages from blocking hooks due to incorrect message delivery mechanisms. By aligning message fields and exit codes with the Claude Code hooks specification, the system now ensures that Claude receives critical feedback, understands why actions are blocked, and gets relevant contextual information, leading to a more robust and transparent AI-assisted development workflow. Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request correctly refactors the hook messaging to align with the Claude Code hooks specification, moving away from the user-only systemMessage to Claude-visible fields like permissionDecisionReason and additionalContext. The changes are comprehensive, covering Python rule engines, shell script hooks, and documentation. Error reporting is also improved to be visible to Claude. I've identified one area for improvement in the new struggle-inject.sh script to enhance robustness when generating JSON, but overall the changes are excellent and address a critical functionality gap.
| cat <<EOF | ||
| { | ||
| "hookSpecificOutput": { | ||
| "hookEventName": "UserPromptSubmit", | ||
| "additionalContext": "STRUGGLE DETECTOR ($CONSECUTIVE consecutive uncertain responses): Signals — $RECENT_SIGNALS. Consider spawning a deep-think-partner agent (Task tool, subagent_type='deep-think-partner') for thorough analysis instead of continuing to iterate." | ||
| } | ||
| } | ||
| EOF |
There was a problem hiding this comment.
While the current implementation is likely safe because the $RECENT_SIGNALS variable is constructed from known-safe strings, it's fragile. If a future change to struggle-detector.sh introduces signals containing special characters (like double quotes), it could break the JSON output. Using jq to construct the JSON is a more robust and secure approach that prevents potential injection vulnerabilities.
| cat <<EOF | |
| { | |
| "hookSpecificOutput": { | |
| "hookEventName": "UserPromptSubmit", | |
| "additionalContext": "STRUGGLE DETECTOR ($CONSECUTIVE consecutive uncertain responses): Signals — $RECENT_SIGNALS. Consider spawning a deep-think-partner agent (Task tool, subagent_type='deep-think-partner') for thorough analysis instead of continuing to iterate." | |
| } | |
| } | |
| EOF | |
| CONTEXT_MSG="STRUGGLE DETECTOR ($CONSECUTIVE consecutive uncertain responses): Signals — $RECENT_SIGNALS. Consider spawning a deep-think-partner agent (Task tool, subagent_type='deep-think-partner') for thorough analysis instead of continuing to iterate." | |
| # Use jq to safely construct the JSON output. | |
| jq -n \ | |
| --arg context "$CONTEXT_MSG" \ | |
| '{ | |
| "hookSpecificOutput": { | |
| "hookEventName": "UserPromptSubmit", | |
| "additionalContext": $context | |
| } | |
| }' |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 78d4adb86c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| CONSECUTIVE=$(cat "$STRUGGLE_COUNT_FILE" 2>/dev/null || echo "0") | ||
| [[ "$CONSECUTIVE" -lt 2 ]] && exit 0 |
There was a problem hiding this comment.
Preserve single-response struggle escalation
struggle-inject.sh now injects guidance only when .struggle-count is >= 2, but before this change the detector escalated on either two consecutive struggles or one very high score (score > 25). With the new split, a severe one-off struggle increments the count to 1 and no suggestion is injected on the next prompt unless a second bad response occurs, so the high-severity path was unintentionally removed.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
This PR fixes a critical bug where Claude never received hook messages when tools were blocked. The root cause was using systemMessage (user-only display field) instead of the correct Claude-facing fields defined in the Claude Code hooks specification.
Changes:
- Migrated all hook responses from
systemMessageto spec-compliant fields:permissionDecisionReason(PreToolUse deny),reason(Stop/PostToolUse block),additionalContext(warnings) - Fixed hook error handling to use exit code 2 with stderr (Claude-visible) instead of exit 0 with JSON output
- Split struggle detection into two-phase system: async Stop hook (analysis) + UserPromptSubmit hook (injection via additionalContext)
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| plugins/hookify/hookify/core/rule_engine.py | Rewrote response generation to use event-specific fields per Claude Code spec; removed all systemMessage usage; split PreToolUse/PostToolUse/Stop into separate branches with correct formats |
| plugins/hookify/hookify/core/hook_runner.py | Changed error handling from systemMessage+exit 0 to stderr+exit 2 so Claude sees hookify crashes |
| plugins/hookify/global-rules/hookify.mtp-smart-test.local.md | Shortened message from 80 lines to 8 lines with directive action items |
| plugins/hookify/CLAUDE.md | Updated documentation to explain correct field usage and note that systemMessage is user-only |
| plugins/metacognitive-guard/hooks/scripts/epistemic-guard.sh | Migrated 5 blocking responses from deprecated decision/reason format to hookSpecificOutput.permissionDecision/permissionDecisionReason |
| plugins/metacognitive-guard/hooks/scripts/integrity-check.sh | Fixed exit code 1 (non-blocking) to exit 2 (blocking) with stderr output so violations actually block commits |
| plugins/metacognitive-guard/hooks/scripts/struggle-detector.sh | Removed systemMessage output; now only writes to blackboard for async delivery |
| plugins/metacognitive-guard/hooks/scripts/struggle-inject.sh | New UserPromptSubmit hook that reads blackboard state and injects struggle detection via additionalContext |
| plugins/metacognitive-guard/hooks/hooks.json | Added UserPromptSubmit hook configuration for struggle-inject.sh |
| plugins/metacognitive-guard/CLAUDE.md | Updated to document two-part struggle detection system |
| CHANGELOG.md | Comprehensive documentation of all fixes under Fixed section |
| SIGNALS_FILE="$BLACKBOARD/.struggle-signals" | ||
| RECENT_SIGNALS="" | ||
| if [[ -f "$SIGNALS_FILE" ]]; then | ||
| RECENT_SIGNALS=$(tail -20 "$SIGNALS_FILE" | grep ' - ' | sed 's/ - //' | tr '\n' ', ' | sed 's/, $//') |
There was a problem hiding this comment.
The grep ' - ' command on line 33 may fail when there are no matching lines, causing the script to exit early due to set -euo pipefail on line 17. This would prevent the struggle detection message from being injected even when the count threshold is met.
Consider adding || true to handle the case when grep finds no matches, similar to how grep is used elsewhere in this codebase (e.g., ralph-loop.sh uses grep -q with proper error handling).
| RECENT_SIGNALS=$(tail -20 "$SIGNALS_FILE" | grep ' - ' | sed 's/ - //' | tr '\n' ', ' | sed 's/, $//') | |
| RECENT_SIGNALS=$((tail -20 "$SIGNALS_FILE" | grep ' - ' || true) | sed 's/ - //' | tr '\n' ', ' | sed 's/, $//') |
| cat <<EOF | ||
| { | ||
| "hookSpecificOutput": { | ||
| "hookEventName": "UserPromptSubmit", | ||
| "additionalContext": "STRUGGLE DETECTOR ($CONSECUTIVE consecutive uncertain responses): Signals — $RECENT_SIGNALS. Consider spawning a deep-think-partner agent (Task tool, subagent_type='deep-think-partner') for thorough analysis instead of continuing to iterate." | ||
| } | ||
| } | ||
| EOF |
There was a problem hiding this comment.
The $RECENT_SIGNALS variable is directly interpolated into the JSON output without escaping. If the signals file contains special characters like quotes or newlines, this could break the JSON structure or allow injection attacks.
Consider using jq to properly escape the variable, similar to how truth-beacon.sh handles context injection (line 72 of truth-beacon.sh uses jq -n --arg ctx "$CONTEXT"). For example:
jq -n --arg consecutive "$CONSECUTIVE" --arg signals "$RECENT_SIGNALS" '{
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: "STRUGGLE DETECTOR (\($consecutive) consecutive uncertain responses): Signals — \($signals). Consider spawning a deep-think-partner agent (Task tool, subagent_type='\''deep-think-partner'\'') for thorough analysis instead of continuing to iterate."
}
}'| cat <<EOF | |
| { | |
| "hookSpecificOutput": { | |
| "hookEventName": "UserPromptSubmit", | |
| "additionalContext": "STRUGGLE DETECTOR ($CONSECUTIVE consecutive uncertain responses): Signals — $RECENT_SIGNALS. Consider spawning a deep-think-partner agent (Task tool, subagent_type='deep-think-partner') for thorough analysis instead of continuing to iterate." | |
| } | |
| } | |
| EOF | |
| jq -n --arg consecutive "$CONSECUTIVE" --arg signals "$RECENT_SIGNALS" '{ | |
| hookSpecificOutput: { | |
| hookEventName: "UserPromptSubmit", | |
| additionalContext: "STRUGGLE DETECTOR (\($consecutive) consecutive uncertain responses): Signals — \($signals). Consider spawning a deep-think-partner agent (Task tool, subagent_type='\''deep-think-partner'\'') for thorough analysis instead of continuing to iterate." | |
| } | |
| }' |
Review feedback: the empty hookSpecificOutput with only hookEventName serves no purpose — decision/reason are top-level fields for PostToolUse. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…wers - grep under set -e: add || true to prevent exit 1 when no signal matches - JSON escaping: use jq instead of raw heredoc for safe JSON construction - Lost high-severity path: score > 25 now sets count to 2 immediately, preserving single-response escalation that was lost in the split Flagged by: Gemini, Copilot, Codex (ChatGPT) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| # Inject as additionalContext — use jq for safe JSON construction | ||
| MSG="STRUGGLE DETECTOR ($CONSECUTIVE consecutive uncertain responses): Signals — $RECENT_SIGNALS. Consider spawning a deep-think-partner agent (Task tool, subagent_type='deep-think-partner') for thorough analysis instead of continuing to iterate." | ||
| jq -n --arg ctx "$MSG" '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":$ctx}}' |
There was a problem hiding this comment.
struggle-inject.sh assumes jq is available. If jq is missing, the script will exit non-zero under set -e and the hook won’t inject any additionalContext. Other hooks in this plugin (e.g., truth-beacon.sh) include a jq/no-jq fallback—consider adding the same here so the hook degrades gracefully.
| # Inject as additionalContext — use jq for safe JSON construction | |
| MSG="STRUGGLE DETECTOR ($CONSECUTIVE consecutive uncertain responses): Signals — $RECENT_SIGNALS. Consider spawning a deep-think-partner agent (Task tool, subagent_type='deep-think-partner') for thorough analysis instead of continuing to iterate." | |
| jq -n --arg ctx "$MSG" '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":$ctx}}' | |
| # Inject as additionalContext — prefer jq for safe JSON construction, but degrade gracefully if missing | |
| MSG="STRUGGLE DETECTOR ($CONSECUTIVE consecutive uncertain responses): Signals — $RECENT_SIGNALS. Consider spawning a deep-think-partner agent (Task tool, subagent_type='deep-think-partner') for thorough analysis instead of continuing to iterate." | |
| if command -v jq >/dev/null 2>&1; then | |
| jq -n --arg ctx "$MSG" '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":$ctx}}' | |
| else | |
| # Fallback: emit JSON using basic shell escaping when jq is not available | |
| ESCAPED_MSG=$(printf '%s' "$MSG" | sed 's/\\/\\\\/g; s/"/\\"/g') | |
| printf '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"%s"}}\n' "$ESCAPED_MSG" | |
| fi |
| STRUGGLE_COUNT_FILE="$BLACKBOARD/.struggle-count" | ||
|
|
||
| # Check consecutive struggle count | ||
| CONSECUTIVE=$(cat "$STRUGGLE_COUNT_FILE" 2>/dev/null || echo "0") |
There was a problem hiding this comment.
CONSECUTIVE is read from a file and then compared with -lt. If the file content is empty or non-numeric, the comparison returns an error and the script may proceed as if threshold was met. Consider normalizing CONSECUTIVE to an integer (default 0) before the numeric comparison.
| CONSECUTIVE=$(cat "$STRUGGLE_COUNT_FILE" 2>/dev/null || echo "0") | |
| CONSECUTIVE=$(cat "$STRUGGLE_COUNT_FILE" 2>/dev/null || echo "0") | |
| # Normalize to integer; default to 0 if empty or non-numeric | |
| if ! [[ "$CONSECUTIVE" =~ ^[0-9]+$ ]]; then | |
| CONSECUTIVE=0 | |
| fi |
| # Exit code 2 = blocking error, stderr is fed to Claude | ||
| echo "COMMIT INTEGRITY: $VIOLATION_COUNT violations found. Fix these before committing:" >&2 | ||
| for v in "${VIOLATIONS[@]}"; do | ||
| echo " - $v" >&2 | ||
| done | ||
| echo "These patterns indicate shortcuts that will cause problems later." >&2 |
There was a problem hiding this comment.
In the blocking path, the hook relies on exit 2 + stderr for Claude-visible output, but it currently only prints the compact VIOLATIONS[@] entries to stderr. The more detailed context emitted earlier in the script goes to stdout and may not be visible to Claude when blocking. Consider writing the key violation details/locations to stderr as well (or redirecting stdout to stderr in the violation case) so Claude gets actionable guidance.
| # Note: No JSON output needed here. The struggle-inject.sh (UserPromptSubmit) | ||
| # reads .blackboard/.struggle-count and injects additionalContext to Claude | ||
| # on the next prompt. This async Stop hook only does analysis + blackboard writes. |
There was a problem hiding this comment.
This section now intentionally produces no JSON output, but the surrounding section header/comments still describe "OUTPUT SUGGESTION IF THRESHOLD MET". Updating the header/comments to match the new behavior will avoid confusion when maintaining the hook.
| # Use additionalContext where Claude can see it | ||
| if hook_event in ['PostToolUse', 'UserPromptSubmit', 'SessionStart']: | ||
| return { | ||
| "hookSpecificOutput": { | ||
| "hookEventName": hook_event, | ||
| "additionalContext": combined_warning | ||
| } | ||
| } | ||
| else: | ||
| # PreToolUse warnings can't reach Claude without blocking | ||
| # No audience for the message — skip | ||
| return {} |
There was a problem hiding this comment.
action: warn rules for PreToolUse are currently dropped (function returns {} for warnings when hook_event is PreToolUse). This makes warnings effectively no-op for the most common hook path, and contradicts the documented "warn but allow" behavior. Consider emitting a user-visible warning (e.g., via systemMessage) or, if supported by the Claude Code hooks spec, returning a PreToolUse hookSpecificOutput with permissionDecision: "allow" plus a reason field so Claude can see the warning without blocking.
Summary
hookifyandmetacognitive-guardusedsystemMessageto communicate with Claude when blocking tools. Per the Claude Code hooks spec,systemMessageis displayed to the user only — Claude never receives it. This made every blocking hook's guidance invisible to Claude.permissionDecisionReason,reason,additionalContext) per event type. Removed allsystemMessageusage.systemMessage+ exit 0.decision/reasonformat to modernhookSpecificOutputwithpermissionDecisionReason.struggle-inject.shon UserPromptSubmit (injectsadditionalContextClaude sees).Test plan
dotnet testwithout--filter-— verify Claude sees the blocking message and retries with# VERIFYor a filterDateTime.Now— verify Claude sees the epistemic guard correction#pragma warning disableand rungit commit— verify commit is blockedadditionalContext🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes