Summary
Stop hooks that return exit code 2 (to force agent continuation) work correctly when installed in .claude/hooks/ but fail when installed via the plugin system, displaying ⏺ Stop hook prevented continuation and halting instead of continuing.
Expected Behavior
When a Stop hook:
- Exits with code 2
- Outputs JSON to stderr containing
"decision": "block" and a "reason" field
Claude Code should:
- Parse the JSON from stderr
- Extract the
reason field
- Feed the reason to Claude as continuation instructions
- Resume agent execution
This is the documented behavior and works correctly for hooks in .claude/hooks/.
Actual Behavior (Plugin Hooks Only)
When the same hook is installed via plugins:
- Hook executes and returns exit code 2 with JSON to stderr
- Claude Code displays:
⏺ Stop hook prevented continuation
- Agent execution halts instead of continuing
Reproduction Evidence
Test script that proves identical behavior from both hook locations:
```bash
Create test transcript with reflexive agreement pattern
cat > /tmp/test_transcript.json << 'INNER_EOF'
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"You're wrong"}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"You're absolutely right."}]}}
INNER_EOF
Test direct hook
echo '{"session_id":"test","stop_hook_active":false,"transcript_path":"/tmp/test_transcript.json"}' |
~/.claude/hooks/entrypoints/stop.rb 2>&1
echo "Exit: $?"
Test plugin hook
echo '{"session_id":"test","stop_hook_active":false,"transcript_path":"/tmp/test_transcript.json"}' |
~/.claude/plugins/marketplaces/simpleclaude/plugins/sc-hooks/hooks/entrypoints/stop.rb 2>&1
echo "Exit: $?"
```
Both produce identical output:
```json
{"continue":true,"stopReason":"","suppressOutput":false,"decision":"block","reason":"I notice I just used a reflexive agreement phrase. Let me provide a more substantive response:\n\nInstead of simply agreeing, let me analyze your point with specific technical reasoning, consider potential edge cases or alternative approaches, and offer constructive insights that build collaboratively on your observation."}
```
Exit code: 2 (both)
Root Cause
The hook implementation is correct. Claude Code has different code paths for:
- Direct hooks (
.claude/hooks/): Exit 2 → parse stderr → continue ✓
- Plugin hooks (
plugins/): Exit 2 → show error message → halt ✗
The plugin execution path appears to:
- Not read stderr properly, OR
- Not parse the JSON from stderr, OR
- Not extract/use the
reason field
Environment
- Platform: macOS
- Claude Code Version: 0.3.10
- Hook Implementation: Uses
claude_hooks Ruby gem v0.7.0
- Plugin: SimpleClaude (
sc-hooks)
Workaround
Moving the Stop hook from plugins/sc-hooks/hooks/ to .claude/hooks/ makes it work correctly.
Related
Original discussion: gabriel-dehan/claude_hooks#11
Summary
Stop hooks that return exit code 2 (to force agent continuation) work correctly when installed in
.claude/hooks/but fail when installed via the plugin system, displaying⏺ Stop hook prevented continuationand halting instead of continuing.Expected Behavior
When a Stop hook:
"decision": "block"and a"reason"fieldClaude Code should:
reasonfieldThis is the documented behavior and works correctly for hooks in
.claude/hooks/.Actual Behavior (Plugin Hooks Only)
When the same hook is installed via plugins:
⏺ Stop hook prevented continuationReproduction Evidence
Test script that proves identical behavior from both hook locations:
```bash
Create test transcript with reflexive agreement pattern
cat > /tmp/test_transcript.json << 'INNER_EOF'
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"You're wrong"}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"You're absolutely right."}]}}
INNER_EOF
Test direct hook
echo '{"session_id":"test","stop_hook_active":false,"transcript_path":"/tmp/test_transcript.json"}' |
~/.claude/hooks/entrypoints/stop.rb 2>&1
echo "Exit: $?"
Test plugin hook
echo '{"session_id":"test","stop_hook_active":false,"transcript_path":"/tmp/test_transcript.json"}' |
~/.claude/plugins/marketplaces/simpleclaude/plugins/sc-hooks/hooks/entrypoints/stop.rb 2>&1
echo "Exit: $?"
```
Both produce identical output:
```json
{"continue":true,"stopReason":"","suppressOutput":false,"decision":"block","reason":"I notice I just used a reflexive agreement phrase. Let me provide a more substantive response:\n\nInstead of simply agreeing, let me analyze your point with specific technical reasoning, consider potential edge cases or alternative approaches, and offer constructive insights that build collaboratively on your observation."}
```
Exit code: 2 (both)
Root Cause
The hook implementation is correct. Claude Code has different code paths for:
.claude/hooks/): Exit 2 → parse stderr → continue ✓plugins/): Exit 2 → show error message → halt ✗The plugin execution path appears to:
reasonfieldEnvironment
claude_hooksRuby gem v0.7.0sc-hooks)Workaround
Moving the Stop hook from
plugins/sc-hooks/hooks/to.claude/hooks/makes it work correctly.Related
Original discussion: gabriel-dehan/claude_hooks#11