Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:02:50 +08:00
commit fb4ad00ae4
9 changed files with 882 additions and 0 deletions

36
hooks/hooks.json Normal file
View File

@@ -0,0 +1,36 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "Write|Edit|mcp__serena__create_text_file|mcp__serena__replace_regex|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|mcp__serena__create_text_file|mcp__serena__replace_regex|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use.sh"
}
]
}
]
}
}

266
hooks/post-tool-use.sh Executable file
View File

@@ -0,0 +1,266 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================================
# Load Logger Library
# ============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/logger.sh"
# ============================================================================
# PART -1: Check if required commands are installed
# ============================================================================
if ! command -v yq &> /dev/null; then
output=$(cat <<'EOF'
{
"decision": "block",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "yq is not installed. Install it to enable hook functionality.\n macOS: brew install yq\n Linux: https://github.com/mikefarah/yq#install"
}
}
EOF
)
log_hook_output "post-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
fi
if ! command -v node &> /dev/null; then
output=$(cat <<'EOF'
{
"decision": "block",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Node.js is not installed. Install it to enable hook functionality.\n macOS: brew install node\n Linux: https://nodejs.org/en/download/package-manager"
}
}
EOF
)
log_hook_output "post-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
fi
if ! command -v npx &> /dev/null; then
output=$(cat <<'EOF'
{
"decision": "block",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "npx is not installed. Install it to enable hook functionality.\n npm install -g npx"
}
}
EOF
)
log_hook_output "post-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
fi
# Determine which JSON command to use (prefer installed json, fallback to npx)
if command -v json &> /dev/null; then
JSON_CMD="json"
else
JSON_CMD="npx -y json"
fi
# Read JSON input
json=$(cat)
# Extract file path and tool name
# For Write/Edit tools, the file path is in tool_response.filePath
# For Serena MCP tools, we need to get it from the tool_input.relative_path
file_path=$(echo "$json" | $JSON_CMD tool_response.filePath 2>/dev/null || echo "")
if [[ -z "$file_path" ]]; then
file_path=$(echo "$json" | $JSON_CMD tool_input.relative_path 2>/dev/null || echo "")
fi
tool_name=$(echo "$json" | $JSON_CMD tool_name 2>/dev/null || echo "")
# ============================================================================
# PART 0: Track Changed Files in In-Progress Task
# ============================================================================
if [[ -n "$file_path" ]]; then
# Skip tracking task yaml files and requirements.yaml
filename=$(basename "$file_path")
if [[ "$filename" =~ ^task-.*\.ya?ml$ ]] || [[ "$filename" == "requirements.yaml" ]]; then
# Don't track these files
:
else
# Find the task file with "status: in progress"
in_progress_task=""
for task_file in task-*.yaml task-*.yml; do
if [[ -f "$task_file" ]]; then
# Check if task has "in progress" status using yq
task_status=$(yq -r '.status // ""' "$task_file" 2>/dev/null || echo "")
# Remove quotes if present
if [[ "$task_status" =~ ^[\"\'](.*)[\"\']$ ]]; then
task_status="${BASH_REMATCH[1]}"
fi
if [[ "$task_status" == "in progress" ]]; then
in_progress_task="$task_file"
break
fi
fi
done
# If we found an in-progress task, add the file to changed_files if not already there
if [[ -n "$in_progress_task" ]]; then
# Check if file is already in changed_files array
file_exists=$(yq -r ".changed_files // [] | map(select(. == \"$file_path\")) | length" "$in_progress_task" 2>/dev/null || echo "0")
if [[ "$file_exists" == "0" ]]; then
# Add file to changed_files array
yq -i ".changed_files += [\"$file_path\"]" "$in_progress_task"
fi
fi
fi
fi
# ============================================================================
# PART 1: Verify Requirements
# ============================================================================
if [[ -n "$file_path" ]] && [[ "$file_path" == *"requirements.yaml" ]]; then
if [[ -f "$file_path" ]]; then
if yq -e '.complete == true' "$file_path" >/dev/null 2>&1; then
# Step 1: Get full Claude output
claude_output=$(claude -p "read and execute ${CLAUDE_PLUGIN_ROOT}/context/verify-requirements.md")
# Step 2: Extract YAML from output, removing any markdown code fences
yaml=$(echo "$claude_output" | awk '/^passed:/{flag=1} flag' | sed '/^```/d')
passed=$(echo "$yaml" | yq -r '.passed')
remarks=$(echo "$yaml" | yq -r '.remarks[]?')
if [[ "$passed" == "false" ]]; then
# Use jq to properly construct JSON with escaped content
output=$(jq -n \
--arg remarks "$remarks" \
--arg claude_output "$claude_output" \
'{
decision: "block",
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: ("Requirements verification failed and needs fixing with the following reasons:\n" + $remarks + "\n\nFull verification output:\n" + $claude_output)
}
}')
log_hook_output "post-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
else
# Log successful verification
success_output=$(cat <<EOF
{
"decision": "allow",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Requirements verification passed successfully"
}
}
EOF
)
log_hook_output "post-tool-use" "$success_output"
fi
fi
fi
fi
# ============================================================================
# PART 2: Verify Task Creation
# ============================================================================
if [[ -n "$file_path" ]] && [[ "$file_path" =~ task-[0-9]+\.yaml$ ]]; then
# Only verify when the task file is created (Write or mcp__serena__create_text_file tool used)
if [[ "$tool_name" == "Write" || "$tool_name" == "mcp__serena__create_text_file" ]] && [[ -f "$file_path" ]]; then
# Step 1: Get full Claude output
claude_output=$(claude -p "read task described in $file_path and execute ${CLAUDE_PLUGIN_ROOT}/context/verify-task.md")
# Step 2: Extract YAML from output, removing any markdown code fences
yaml=$(echo "$claude_output" | awk '/^passed:/{flag=1} flag' | sed '/^```/d')
passed=$(echo "$yaml" | yq -r '.passed')
remarks=$(echo "$yaml" | yq -r '.remarks[]?')
if [[ "$passed" == "false" ]]; then
# Use jq to properly construct JSON with escaped content
output=$(jq -n \
--arg remarks "$remarks" \
--arg claude_output "$claude_output" \
'{
decision: "block",
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: ("Task verification failed and needs fixing with the following reasons:\n" + $remarks + "\n\nFull verification output:\n" + $claude_output)
}
}')
log_hook_output "post-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
else
# Log successful verification
task_name=$(basename "$file_path")
success_output=$(cat <<EOF
{
"decision": "allow",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Task verification passed successfully for $task_name"
}
}
EOF
)
log_hook_output "post-tool-use" "$success_output"
fi
fi
fi
# ============================================================================
# PART 3: Verify Content
# ============================================================================
if [[ -n "$file_path" ]]; then
extension="${file_path##*.}"
if [[ "$extension" == "yaml" || "$extension" == "yml" ]]; then
commands=(
"yq eval ."
)
else
exit 0
fi
for cmd in "${commands[@]}"; do
# Use timeout if available, otherwise run directly
if command -v timeout &> /dev/null; then
if ! timeout 30 $cmd "$file_path" 1>&2; then
# Use jq to properly construct JSON with escaped content
output=$(jq -n \
--arg cmd "$cmd" \
--arg file_path "$file_path" \
'{
decision: "block",
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: ("Command failed: " + $cmd + " for " + $file_path)
}
}')
log_hook_output "post-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
fi
fi
done
fi
# All checks passed - log successful execution with no blocking output
log_hook_output "post-tool-use" ""
exit 0

339
hooks/pre-tool-use.sh Executable file
View File

@@ -0,0 +1,339 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================================
# Load Logger Library
# ============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/logger.sh"
# ============================================================================
# PART -1: Check if required commands are installed
# ============================================================================
if ! command -v yq &> /dev/null; then
output=$(cat <<'EOF'
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "yq is not installed. Install it to enable hook functionality.\n macOS: brew install yq\n Linux: https://github.com/mikefarah/yq#install"
}
}
EOF
)
log_hook_output "pre-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
fi
if ! command -v node &> /dev/null; then
output=$(cat <<'EOF'
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Node.js is not installed. Install it to enable hook functionality.\n macOS: brew install node\n Linux: https://nodejs.org/en/download/package-manager"
}
}
EOF
)
log_hook_output "pre-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
fi
if ! command -v npx &> /dev/null; then
output=$(cat <<'EOF'
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "npx is not installed. Install it to enable hook functionality.\n npm install -g npx"
}
}
EOF
)
log_hook_output "pre-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
fi
# Determine which JSON command to use (prefer installed json, fallback to npx)
if command -v json &> /dev/null; then
JSON_CMD="json"
else
JSON_CMD="npx -y json"
fi
# Read JSON input
json=$(cat)
# Extract file path (try both file_path and relative_path for Serena MCP tools)
file_path=$(echo "$json" | $JSON_CMD tool_input.file_path 2>/dev/null || echo "")
if [[ -z "$file_path" ]]; then
file_path=$(echo "$json" | $JSON_CMD tool_input.relative_path 2>/dev/null || echo "")
fi
# ============================================================================
# PART 2: Verify Task
# ============================================================================
if [[ "$file_path" =~ task-[0-9]+\.yaml$ ]]; then
if [[ -f "$file_path" ]]; then
# Extract old and new status from the hook JSON
old_string=$(echo "$json" | $JSON_CMD tool_input.old_string 2>/dev/null || echo "")
new_string=$(echo "$json" | $JSON_CMD tool_input.new_string 2>/dev/null || echo "")
current_status=""
new_status=""
old_has_status=false
new_has_status=false
# Parse status from old_string (format: "status: <value>" or "status: \"<value>\"" or "status: '<value>'")
# Use grep to extract just the status line, handling multiline strings
# Use grep -m 1 to only get the first match in case of multiline content
if status_line=$(echo "$old_string" | grep -m 1 -E '^status:[[:space:]]*'); then
# Match quoted or unquoted status values (including multi-word statuses)
if [[ "$status_line" =~ ^status:[[:space:]]*\"([^\"]+)\" ]]; then
current_status="${BASH_REMATCH[1]}"
old_has_status=true
elif [[ "$status_line" =~ ^status:[[:space:]]*\'([^\']+)\' ]]; then
current_status="${BASH_REMATCH[1]}"
old_has_status=true
elif [[ "$status_line" =~ ^status:[[:space:]]*(.+)$ ]]; then
# For unquoted values, capture everything after "status:" until end of line
current_status=$(echo "${BASH_REMATCH[1]}" | sed 's/[[:space:]]*$//')
old_has_status=true
fi
fi
# Parse status from new_string (format: "status: <value>" or "status: \"<value>\"" or "status: '<value>'")
# Use grep to extract just the status line, handling multiline strings
# Use grep -m 1 to only get the first match in case of multiline content
if status_line=$(echo "$new_string" | grep -m 1 -E '^status:[[:space:]]*'); then
# Match quoted or unquoted status values (including multi-word statuses)
if [[ "$status_line" =~ ^status:[[:space:]]*\"([^\"]+)\" ]]; then
new_status="${BASH_REMATCH[1]}"
new_has_status=true
elif [[ "$status_line" =~ ^status:[[:space:]]*\'([^\']+)\' ]]; then
new_status="${BASH_REMATCH[1]}"
new_has_status=true
elif [[ "$status_line" =~ ^status:[[:space:]]*(.+)$ ]]; then
# For unquoted values, capture everything after "status:" until end of line
new_status=$(echo "${BASH_REMATCH[1]}" | sed 's/[[:space:]]*$//')
new_has_status=true
fi
fi
# Check for status field removal
if [[ "$old_has_status" == true && "$new_has_status" == false ]]; then
output=$(cat <<'EOF'
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Removal of status field is not allowed"
}
}
EOF
)
log_hook_output "pre-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
fi
# Define valid transitions
# Check if transition is valid
if [[ -n "$new_status" && "$new_status" != "$current_status" ]]; then
valid_transition=false
valid_next=()
case "$current_status" in
"")
valid_next=("to do")
;;
"to do")
valid_next=("in progress")
;;
"in progress")
valid_next=("under review")
;;
"under review")
valid_next=("done" "in progress")
;;
"done")
valid_next=()
;;
*)
valid_next=()
;;
esac
# Check if new_status is in valid_next array
for allowed in "${valid_next[@]}"; do
[[ "$new_status" == "$allowed" ]] && valid_transition=true && break
done
# Format expected_next for error message
expected_next=$(IFS=' or '; echo "${valid_next[*]}")
[[ -z "$expected_next" ]] && expected_next="<none>"
if [[ "$valid_transition" == false ]]; then
output=$(cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Invalid status transition: '${current_status:-<empty>}' → '$new_status'\\nExpected: '${current_status:-<empty>}' → '${expected_next}'"
}
}
EOF
)
log_hook_output "pre-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
fi
fi
# Function to extract status from file, handling quotes
extract_file_status() {
local file="$1"
local raw_status=$(yq -r '.status // ""' "$file")
# Remove surrounding quotes if present
if [[ "$raw_status" =~ ^[\"\'](.*)[\"\']$ ]]; then
echo "${BASH_REMATCH[1]}"
else
echo "$raw_status"
fi
}
# Specific status validations
case "$new_status" in
"in progress")
# Check if any OTHER task is in "under review" status
# (Allow setting the same file back to "in progress" from "under review")
current_basename=$(basename "$file_path")
for task_file in task-*.yaml; do
if [[ "$task_file" != "$current_basename" && -f "$task_file" ]]; then
other_status=$(extract_file_status "$task_file")
if [[ "$other_status" == "under review" ]]; then
output=$(cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Cannot set task to 'in progress' while $task_file is 'under review'"
}
}
EOF
)
log_hook_output "pre-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
fi
fi
done
;;
esac
fi
fi
# ============================================================================
# PART 3: Commit Changed Files When Task is Done
# ============================================================================
if [[ -n "$file_path" ]]; then
filename=$(basename "$file_path")
# Check if this is a task file that will be modified
if [[ "$filename" =~ ^task-.*\.ya?ml$ ]] && [[ -f "$file_path" ]]; then
# Extract new status from the hook JSON
new_string=$(echo "$json" | $JSON_CMD tool_input.new_string 2>/dev/null || echo "")
# Check if the new status will be "done"
new_status=""
if status_line=$(echo "$new_string" | grep -m 1 -E '^status:[[:space:]]*'); then
# Match quoted or unquoted status values (including multi-word statuses)
if [[ "$status_line" =~ ^status:[[:space:]]*\"([^\"]+)\" ]]; then
new_status="${BASH_REMATCH[1]}"
elif [[ "$status_line" =~ ^status:[[:space:]]*\'([^\']+)\' ]]; then
new_status="${BASH_REMATCH[1]}"
elif [[ "$status_line" =~ ^status:[[:space:]]*(.+)$ ]]; then
# For unquoted values, capture everything after "status:" until end of line
new_status=$(echo "${BASH_REMATCH[1]}" | sed 's/[[:space:]]*$//')
fi
fi
if [[ "$new_status" == "done" ]]; then
# Get the changed files and commit message
changed_files_array=()
while IFS= read -r line; do
[[ -n "$line" ]] && changed_files_array+=("$line")
done < <(yq -r '.changed_files[]?' "$file_path" 2>/dev/null || true)
commit_message=$(yq -r '.commit_message // ""' "$file_path" 2>/dev/null || echo "")
# Remove quotes from commit message if present
if [[ "$commit_message" =~ ^[\"\'](.*)[\"\']$ ]]; then
commit_message="${BASH_REMATCH[1]}"
fi
# Only proceed if we have both changed files and a commit message
if [[ ${#changed_files_array[@]} -gt 0 ]] && [[ -n "$commit_message" ]]; then
# Stage each changed file
for file in "${changed_files_array[@]}"; do
if [[ -n "$file" ]] && [[ -f "$file" ]]; then
if ! git add "$file" 2>&1 >&2; then
output=$(cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Failed to stage file: $file"
}
}
EOF
)
log_hook_output "pre-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
fi
fi
done
# Create the commit with the task's commit message
if ! git diff --cached --quiet 2>/dev/null; then
if ! git commit -m "$commit_message" > >(cat >&2) 2>&1; then
output=$(cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Failed to commit changes for task $filename"
}
}
EOF
)
log_hook_output "pre-tool-use" "$output"
check_and_echo_block_reason "$output"
echo "$output"
exit 1
fi
echo "Committed changes for task $filename"
fi
fi
fi
fi
fi
# All checks passed - log successful execution with no blocking output
log_hook_output "pre-tool-use" ""
exit 0

89
hooks/session-start.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================================
# Load Logger Library
# ============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/logger.sh"
# ============================================================================
# Session Start Hook - Check Required Dependencies
# ============================================================================
# This hook runs at the start of each Claude Code session to verify that
# all required dependencies are installed for the rgw workflow hooks.
# ============================================================================
missing_deps=()
# Check for yq (YAML processor)
if ! command -v yq &> /dev/null; then
missing_deps+=("yq")
fi
# Check for node (required for json npm package)
if ! command -v node &> /dev/null; then
missing_deps+=("node")
fi
# Check for npx (required to run json package)
if ! command -v npx &> /dev/null; then
missing_deps+=("npx")
fi
# If there are missing dependencies, display installation instructions
if [ ${#missing_deps[@]} -gt 0 ]; then
message="⚠️ Missing required dependencies for rgw workflow hooks:\\n\\n"
for dep in "${missing_deps[@]}"; do
case "$dep" in
yq)
message+=" 📦 yq (YAML processor)\\n"
message+=" macOS: brew install yq\\n"
message+=" Linux: https://github.com/mikefarah/yq#install\\n"
message+="\\n"
;;
node)
message+=" 📦 Node.js (JavaScript runtime)\\n"
message+=" macOS: brew install node\\n"
message+=" Linux: https://nodejs.org/en/download/package-manager\\n"
message+="\\n"
;;
npx)
message+=" 📦 npx (npm package runner)\\n"
message+=" Usually installed with Node.js\\n"
message+=" If missing: npm install -g npx\\n"
message+="\\n"
;;
esac
done
message+=" Install the missing dependencies to enable full hook functionality."
output=$(cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "$message"
}
}
EOF
)
log_hook_output "session-start" "$output"
echo "$output"
else
output=$(cat <<'EOF'
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "✅ rgw workflow hooks: All required dependencies are installed."
}
}
EOF
)
log_hook_output "session-start" "$output"
echo "$output"
fi
# Explicitly flush output
exit 0