Compare commits

..

1 Commits

Author SHA1 Message Date
Claude
411381bf3e fix: Add missing matcher fields to hooks.json files
Stop hooks (and other hook types) were not firing because they were
missing the required "matcher" field. According to the hook development
documentation, all hooks must have a matcher field - "*" for wildcard
matching.

Changes:
- Add matcher: "*" to all hooks in hookify, ralph-wiggum,
  explanatory-output-style, and learning-output-style plugins
- Update validate-hook-schema.sh to properly handle plugin format
  (with 'hooks' wrapper) vs settings format (events at root)
- Add validate-all-hooks.sh script to validate all hooks.json files

Fixes: https://anthropic.slack.com/archives/C08EHE6JF3L/p1765822035850959
2025-12-15 18:14:58 +00:00
9 changed files with 86 additions and 280 deletions

View File

@@ -3,6 +3,7 @@
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",

View File

@@ -3,6 +3,7 @@
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
@@ -14,6 +15,7 @@
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
@@ -25,6 +27,7 @@
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
@@ -36,6 +39,7 @@
],
"UserPromptSubmit": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
@@ -44,17 +48,6 @@
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/notification.py",
"timeout": 10
}
]
}
]
}
}

View File

@@ -1,143 +0,0 @@
#!/usr/bin/env python3
"""Notification hook executor for hookify plugin.
This script is called by Claude Code when notifications are sent.
It formats teammate idle notifications and other IPC messages for display.
"""
import os
import sys
import json
from datetime import datetime
# CRITICAL: Add plugin root to Python path for imports
PLUGIN_ROOT = os.environ.get('CLAUDE_PLUGIN_ROOT')
if PLUGIN_ROOT:
parent_dir = os.path.dirname(PLUGIN_ROOT)
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
if PLUGIN_ROOT not in sys.path:
sys.path.insert(0, PLUGIN_ROOT)
def format_idle_notification(data: dict) -> str:
"""Format an idle notification for display.
Args:
data: The notification data containing type, from, timestamp, etc.
Returns:
Formatted string for display
"""
worker_name = data.get('from', 'worker')
timestamp = data.get('timestamp', '')
# Format timestamp if present
time_str = ''
if timestamp:
try:
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
time_str = dt.strftime('%H:%M:%S')
except (ValueError, AttributeError):
time_str = ''
# Build the formatted output using the suggested format
lines = [f"{worker_name}"]
if time_str:
lines.append(f" ⎿ Status is idle ({time_str})")
else:
lines.append(" ⎿ Status is idle")
return '\n'.join(lines)
def format_notification(notification_content: str) -> dict:
"""Parse and format a notification message.
Args:
notification_content: Raw notification content (may be JSON or plain text)
Returns:
Dict with formatted systemMessage
"""
# Try to parse as JSON first
try:
data = json.loads(notification_content)
# Check if this is an idle notification
if isinstance(data, dict) and data.get('type') == 'idle_notification':
formatted = format_idle_notification(data)
return {"systemMessage": formatted}
# Handle other notification types
notification_type = data.get('type', '') if isinstance(data, dict) else ''
if notification_type == 'status_update':
worker = data.get('from', 'worker')
status = data.get('status', 'unknown')
return {"systemMessage": f"{worker}\n ⎿ Status: {status}"}
if notification_type == 'progress_update':
worker = data.get('from', 'worker')
progress = data.get('progress', '')
return {"systemMessage": f"{worker}\n{progress}"}
# For unknown JSON types, still try to format nicely
if isinstance(data, dict) and 'from' in data:
worker = data.get('from', 'worker')
msg = data.get('message', data.get('status', 'update'))
return {"systemMessage": f"{worker}\n{msg}"}
except (json.JSONDecodeError, TypeError):
# Not JSON, return as-is
pass
return {}
def main():
"""Main entry point for Notification hook."""
try:
# Read input from stdin
input_data = json.load(sys.stdin)
# Get notification content
notification = input_data.get('notification', '')
# Also check for raw notification data in the input
if not notification and input_data.get('type') == 'idle_notification':
# The input itself is an idle notification
formatted = format_idle_notification(input_data)
result = {"systemMessage": formatted}
elif notification:
# Format the notification content
result = format_notification(notification)
else:
# Check if the input looks like an IPC message
if input_data.get('type') in ['idle_notification', 'status_update', 'progress_update']:
if input_data.get('type') == 'idle_notification':
formatted = format_idle_notification(input_data)
result = {"systemMessage": formatted}
else:
worker = input_data.get('from', 'worker')
status = input_data.get('status', input_data.get('type', 'update'))
result = {"systemMessage": f"{worker}\n{status}"}
else:
result = {}
# Always output JSON
print(json.dumps(result), file=sys.stdout)
except Exception as e:
error_output = {
"systemMessage": f"Notification format error: {str(e)}"
}
print(json.dumps(error_output), file=sys.stdout)
finally:
# ALWAYS exit 0
sys.exit(0)
if __name__ == '__main__':
main()

View File

@@ -3,6 +3,7 @@
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",

View File

@@ -1,50 +0,0 @@
#!/bin/bash
# Example: Format teammate idle notification
#
# This script demonstrates how to format raw JSON idle notifications
# into user-friendly display format.
#
# Usage: echo '{"type":"idle_notification","from":"worker-1","timestamp":"..."}' | ./format-idle-notification.sh
set -euo pipefail
# Read JSON from stdin
input=$(cat)
# Parse notification type
notification_type=$(echo "$input" | jq -r '.type // empty' 2>/dev/null || echo "")
if [[ "$notification_type" == "idle_notification" ]]; then
# Extract fields
worker_name=$(echo "$input" | jq -r '.from // "worker"')
timestamp=$(echo "$input" | jq -r '.timestamp // empty')
# Format timestamp if present
time_str=""
if [[ -n "$timestamp" ]]; then
# Try to format the timestamp
time_str=$(date -d "$timestamp" '+%H:%M:%S' 2>/dev/null || echo "")
fi
# Output formatted notification using recommended format:
# ⏺ worker-1
# ⎿ Status is idle
echo "$worker_name"
if [[ -n "$time_str" ]]; then
echo " ⎿ Status is idle ($time_str)"
else
echo " ⎿ Status is idle"
fi
# Output JSON for hook system
if [[ -n "$time_str" ]]; then
jq -n --arg msg "$worker_name\n ⎿ Status is idle ($time_str)" \
'{"systemMessage": $msg}'
else
jq -n --arg msg "$worker_name\n ⎿ Status is idle" \
'{"systemMessage": $msg}'
fi
else
# Not an idle notification, pass through
echo "$input"
fi

View File

@@ -344,69 +344,3 @@ fi
- Per-project settings
- Team-specific rules
- Dynamic validation criteria
## Pattern 11: Format Teammate Idle Notifications
Format raw JSON IPC messages from workers/teammates into user-friendly display:
```json
{
"Notification": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/notification.py",
"timeout": 10
}
]
}
]
}
```
**Example script (format-idle-notification.py):**
```python
#!/usr/bin/env python3
import sys
import json
def format_idle_notification(data):
"""Format idle notification for display."""
worker_name = data.get('from', 'worker')
# Output format:
# ⏺ worker-1
# ⎿ Status is idle
return f"{worker_name}\n ⎿ Status is idle"
def main():
input_data = json.load(sys.stdin)
# Check for idle notification
if input_data.get('type') == 'idle_notification':
formatted = format_idle_notification(input_data)
print(json.dumps({"systemMessage": formatted}))
else:
print(json.dumps({}))
if __name__ == '__main__':
main()
```
**Input (raw JSON IPC message):**
```json
{"type": "idle_notification", "from": "worker-1", "timestamp": "2025-12-15T05:22:40.320Z"}
```
**Output (formatted for display):**
```
⏺ worker-1
⎿ Status is idle
```
**Use for:**
- Formatting teammate/worker status messages
- Converting internal IPC messages to user-friendly display
- Multi-agent swarm coordination UI
- Any notification that shouldn't show raw JSON to users

View File

@@ -40,7 +40,27 @@ echo ""
echo "Checking root structure..."
VALID_EVENTS=("PreToolUse" "PostToolUse" "UserPromptSubmit" "Stop" "SubagentStop" "SessionStart" "SessionEnd" "PreCompact" "Notification")
for event in $(jq -r 'keys[]' "$HOOKS_FILE"); do
# Detect format: plugin format has { description?, hooks: {...} } wrapper
# Settings format has events directly at root level
is_plugin_format=false
if jq -e '.hooks' "$HOOKS_FILE" >/dev/null 2>&1; then
is_plugin_format=true
HOOKS_PATH=".hooks"
echo "Detected plugin format (with 'hooks' wrapper)"
# Validate allowed root keys for plugin format
for key in $(jq -r 'keys[]' "$HOOKS_FILE"); do
if [ "$key" != "hooks" ] && [ "$key" != "description" ]; then
echo "⚠️ Unknown root key in plugin format: $key (expected: 'hooks', 'description')"
fi
done
else
HOOKS_PATH="."
echo "Detected settings format (events at root)"
fi
# Validate event types
for event in $(jq -r "$HOOKS_PATH | keys[]" "$HOOKS_FILE"); do
found=false
for valid_event in "${VALID_EVENTS[@]}"; do
if [ "$event" = "$valid_event" ]; then
@@ -62,12 +82,12 @@ echo "Validating individual hooks..."
error_count=0
warning_count=0
for event in $(jq -r 'keys[]' "$HOOKS_FILE"); do
hook_count=$(jq -r ".\"$event\" | length" "$HOOKS_FILE")
for event in $(jq -r "$HOOKS_PATH | keys[]" "$HOOKS_FILE"); do
hook_count=$(jq -r "$HOOKS_PATH.\"$event\" | length" "$HOOKS_FILE")
for ((i=0; i<hook_count; i++)); do
# Check matcher exists
matcher=$(jq -r ".\"$event\"[$i].matcher // empty" "$HOOKS_FILE")
matcher=$(jq -r "$HOOKS_PATH.\"$event\"[$i].matcher // empty" "$HOOKS_FILE")
if [ -z "$matcher" ]; then
echo "$event[$i]: Missing 'matcher' field"
((error_count++))
@@ -75,7 +95,7 @@ for event in $(jq -r 'keys[]' "$HOOKS_FILE"); do
fi
# Check hooks array exists
hooks=$(jq -r ".\"$event\"[$i].hooks // empty" "$HOOKS_FILE")
hooks=$(jq -r "$HOOKS_PATH.\"$event\"[$i].hooks // empty" "$HOOKS_FILE")
if [ -z "$hooks" ] || [ "$hooks" = "null" ]; then
echo "$event[$i]: Missing 'hooks' array"
((error_count++))
@@ -83,10 +103,10 @@ for event in $(jq -r 'keys[]' "$HOOKS_FILE"); do
fi
# Validate each hook in the array
hook_array_count=$(jq -r ".\"$event\"[$i].hooks | length" "$HOOKS_FILE")
hook_array_count=$(jq -r "$HOOKS_PATH.\"$event\"[$i].hooks | length" "$HOOKS_FILE")
for ((j=0; j<hook_array_count; j++)); do
hook_type=$(jq -r ".\"$event\"[$i].hooks[$j].type // empty" "$HOOKS_FILE")
hook_type=$(jq -r "$HOOKS_PATH.\"$event\"[$i].hooks[$j].type // empty" "$HOOKS_FILE")
if [ -z "$hook_type" ]; then
echo "$event[$i].hooks[$j]: Missing 'type' field"
@@ -102,7 +122,7 @@ for event in $(jq -r 'keys[]' "$HOOKS_FILE"); do
# Check type-specific fields
if [ "$hook_type" = "command" ]; then
command=$(jq -r ".\"$event\"[$i].hooks[$j].command // empty" "$HOOKS_FILE")
command=$(jq -r "$HOOKS_PATH.\"$event\"[$i].hooks[$j].command // empty" "$HOOKS_FILE")
if [ -z "$command" ]; then
echo "$event[$i].hooks[$j]: Command hooks must have 'command' field"
((error_count++))
@@ -114,7 +134,7 @@ for event in $(jq -r 'keys[]' "$HOOKS_FILE"); do
fi
fi
elif [ "$hook_type" = "prompt" ]; then
prompt=$(jq -r ".\"$event\"[$i].hooks[$j].prompt // empty" "$HOOKS_FILE")
prompt=$(jq -r "$HOOKS_PATH.\"$event\"[$i].hooks[$j].prompt // empty" "$HOOKS_FILE")
if [ -z "$prompt" ]; then
echo "$event[$i].hooks[$j]: Prompt hooks must have 'prompt' field"
((error_count++))
@@ -128,7 +148,7 @@ for event in $(jq -r 'keys[]' "$HOOKS_FILE"); do
fi
# Check timeout
timeout=$(jq -r ".\"$event\"[$i].hooks[$j].timeout // empty" "$HOOKS_FILE")
timeout=$(jq -r "$HOOKS_PATH.\"$event\"[$i].hooks[$j].timeout // empty" "$HOOKS_FILE")
if [ -n "$timeout" ] && [ "$timeout" != "null" ]; then
if ! [[ "$timeout" =~ ^[0-9]+$ ]]; then
echo "$event[$i].hooks[$j]: Timeout must be a number"

View File

@@ -3,6 +3,7 @@
"hooks": {
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",

49
scripts/validate-all-hooks.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
# Validate all hooks.json files in the repository
# This script can be run in CI to ensure all plugins have valid hook configurations
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
VALIDATOR="$REPO_ROOT/plugins/plugin-dev/skills/hook-development/scripts/validate-hook-schema.sh"
echo "🔍 Validating all hooks.json files in the repository..."
echo ""
# Find all hooks.json files
mapfile -t HOOKS_FILES < <(find "$REPO_ROOT/plugins" -name "hooks.json" -type f 2>/dev/null)
if [ ${#HOOKS_FILES[@]} -eq 0 ]; then
echo "No hooks.json files found"
exit 0
fi
echo "Found ${#HOOKS_FILES[@]} hooks.json file(s)"
echo ""
errors=0
for hooks_file in "${HOOKS_FILES[@]}"; do
relative_path="${hooks_file#$REPO_ROOT/}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📄 $relative_path"
echo ""
if bash "$VALIDATOR" "$hooks_file"; then
echo ""
else
echo ""
((errors++))
fi
done
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
if [ $errors -eq 0 ]; then
echo "✅ All ${#HOOKS_FILES[@]} hooks.json file(s) are valid!"
exit 0
else
echo "$errors hooks.json file(s) have validation errors"
exit 1
fi