From 328d222e2970ba91a43c7f4494ec6b4ddd9fedbe Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sun, 30 Nov 2025 08:36:27 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 11 + README.md | 3 + hooks/entrypoints/notification.rb | 50 +++ hooks/entrypoints/post_tool_use.rb | 47 +++ hooks/entrypoints/pre_compact.rb | 47 +++ hooks/entrypoints/pre_tool_use.rb | 49 +++ hooks/entrypoints/session_start.rb | 42 +++ hooks/entrypoints/stop.rb | 49 +++ hooks/entrypoints/subagent_stop.rb | 47 +++ hooks/entrypoints/user_prompt_submit.rb | 58 +++ hooks/handlers/auto_format_handler.rb | 244 ++++++++++++ hooks/handlers/copy_message_handler.rb | 213 +++++++++++ hooks/handlers/notification_handler.rb | 68 ++++ hooks/handlers/post_tool_use_handler.rb | 222 +++++++++++ hooks/handlers/pre_compact_handler.rb | 357 ++++++++++++++++++ hooks/handlers/pre_tool_use_handler.rb | 150 ++++++++ .../handlers/reflexive_agreement_detector.rb | 96 +++++ hooks/handlers/session_start_handler.rb | 84 +++++ hooks/handlers/stop_handler.rb | 234 ++++++++++++ hooks/handlers/stop_you_are_not_right.rb | 116 ++++++ hooks/handlers/subagent_stop_handler.rb | 61 +++ hooks/handlers/transcript_parser.rb | 235 ++++++++++++ hooks/handlers/user_prompt_submit_handler.rb | 103 +++++ hooks/hooks.json | 87 +++++ plugin.lock.json | 129 +++++++ 25 files changed, 2802 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100755 hooks/entrypoints/notification.rb create mode 100755 hooks/entrypoints/post_tool_use.rb create mode 100755 hooks/entrypoints/pre_compact.rb create mode 100755 hooks/entrypoints/pre_tool_use.rb create mode 100755 hooks/entrypoints/session_start.rb create mode 100755 hooks/entrypoints/stop.rb create mode 100755 hooks/entrypoints/subagent_stop.rb create mode 100755 hooks/entrypoints/user_prompt_submit.rb create mode 100755 hooks/handlers/auto_format_handler.rb create mode 100755 hooks/handlers/copy_message_handler.rb create mode 100755 hooks/handlers/notification_handler.rb create mode 100755 hooks/handlers/post_tool_use_handler.rb create mode 100755 hooks/handlers/pre_compact_handler.rb create mode 100755 hooks/handlers/pre_tool_use_handler.rb create mode 100755 hooks/handlers/reflexive_agreement_detector.rb create mode 100755 hooks/handlers/session_start_handler.rb create mode 100755 hooks/handlers/stop_handler.rb create mode 100755 hooks/handlers/stop_you_are_not_right.rb create mode 100755 hooks/handlers/subagent_stop_handler.rb create mode 100755 hooks/handlers/transcript_parser.rb create mode 100755 hooks/handlers/user_prompt_submit_handler.rb create mode 100644 hooks/hooks.json create mode 100644 plugin.lock.json diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..b4d8e74 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "sc-hooks", + "description": "SimpleClaude hooks for session management, tool monitoring, and notifications", + "version": "2.1.0", + "author": { + "name": "Kyle Snow Schwartz" + }, + "hooks": [ + "./hooks" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..98228fc --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# sc-hooks + +SimpleClaude hooks for session management, tool monitoring, and notifications diff --git a/hooks/entrypoints/notification.rb b/hooks/entrypoints/notification.rb new file mode 100755 index 0000000..498a43f --- /dev/null +++ b/hooks/entrypoints/notification.rb @@ -0,0 +1,50 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Notification Entrypoint +# +# This entrypoint orchestrates all Notification handlers when Claude Code sends notifications. +# Notifications are triggered when: +# 1. Claude needs permission to use a tool (e.g., "Claude needs your permission to use Bash") +# 2. When prompt input has been idle for at least 60 seconds (e.g., "Claude is waiting for your input") +# +# It reads JSON input from STDIN, executes all configured handlers, merges their outputs, +# and returns the final result to Claude Code via STDOUT. + +require 'claude_hooks' +require 'json' + +# Require all Notification handler classes +require_relative '../handlers/notification_handler' + +begin + # Read input data from Claude Code + input_data = JSON.parse($stdin.read) + + # Initialize and execute all handlers + main_handler = NotificationHandler.new(input_data) + + # Execute handlers + main_handler.call + + # Output result and exit with appropriate code + main_handler.output_and_exit +rescue JSON::ParserError => e + warn "[Notification] JSON parsing error: #{e.message}" + warn JSON.generate({ + continue: true, + stopReason: "Notification hook JSON parsing error: #{e.message}", + suppressOutput: false + }) + exit 1 +rescue StandardError => e + warn "[Notification] Hook execution error: #{e.message}" + warn e.backtrace.join("\n") if ENV['RUBY_CLAUDE_HOOKS_DEBUG'] + + warn JSON.generate({ + continue: true, + stopReason: "Notification hook execution error: #{e.message}", + suppressOutput: false + }) + exit 1 +end diff --git a/hooks/entrypoints/post_tool_use.rb b/hooks/entrypoints/post_tool_use.rb new file mode 100755 index 0000000..3c4c51c --- /dev/null +++ b/hooks/entrypoints/post_tool_use.rb @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# PostToolUse Entrypoint +# +# This entrypoint orchestrates all PostToolUse handlers when Claude Code completes tool execution. +# It reads JSON input from STDIN, executes all configured handlers, merges their outputs, +# and returns the final result to Claude Code via STDOUT. + +require 'claude_hooks' +require 'json' + +# Require all PostToolUse handler classes +# Add additional handler requires here as needed: +require_relative '../handlers/auto_format_handler' + +begin + # Read input data from Claude Code + input_data = JSON.parse($stdin.read) + + # Initialize and execute all handlers + auto_format_handler = AutoFormatHandler.new(input_data) + + # Execute handlers + auto_format_handler.call + + # Output result and exit with appropriate code + auto_format_handler.output_and_exit +rescue JSON::ParserError => e + warn "[PostToolUse] JSON parsing error: #{e.message}" + warn JSON.generate({ + continue: true, + stopReason: "PostToolUse hook JSON parsing error: #{e.message}", + suppressOutput: false + }) + exit 1 +rescue StandardError => e + warn "[PostToolUse] Hook execution error: #{e.message}" + warn e.backtrace.join("\n") if ENV['RUBY_CLAUDE_HOOKS_DEBUG'] + + warn JSON.generate({ + continue: true, + stopReason: "PostToolUse hook execution error: #{e.message}", + suppressOutput: false + }) + exit 1 +end diff --git a/hooks/entrypoints/pre_compact.rb b/hooks/entrypoints/pre_compact.rb new file mode 100755 index 0000000..4cdd12e --- /dev/null +++ b/hooks/entrypoints/pre_compact.rb @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# PreCompact Entrypoint +# +# This entrypoint orchestrates all PreCompact handlers when Claude Code is about to compact the transcript. +# It reads JSON input from STDIN, executes all configured handlers, merges their outputs, +# and returns the final result to Claude Code via STDOUT. + +require 'claude_hooks' +require 'json' + +# Require all PreCompact handler classes +require_relative '../handlers/pre_compact_handler' + +# Add additional handler requires here as needed: +# require_relative '../handlers/pre_compact/transcript_backupper' +# require_relative '../handlers/pre_compact/insight_extractor' +# require_relative '../handlers/pre_compact/summary_generator' + +begin + # Read input data from Claude Code + input_data = JSON.parse($stdin.read) + + + hook = PreCompactHandler.new(input_data) + hook.call + hook.output_and_exit +rescue JSON::ParserError => e + warn "[PreCompact] JSON parsing error: #{e.message}" + puts JSON.generate({ + continue: false, + stopReason: "PreCompact hook JSON parsing error: #{e.message}", + suppressOutput: false + }) + exit 1 # JSON error +rescue StandardError => e + warn "[PreCompact] Hook execution error: #{e.message}" + warn e.backtrace.join("\n") if ENV['RUBY_CLAUDE_HOOKS_DEBUG'] + + puts JSON.generate({ + continue: false, + stopReason: "PreCompact hook execution error: #{e.message}", + suppressOutput: false + }) + exit 1 # General error +end diff --git a/hooks/entrypoints/pre_tool_use.rb b/hooks/entrypoints/pre_tool_use.rb new file mode 100755 index 0000000..620dfba --- /dev/null +++ b/hooks/entrypoints/pre_tool_use.rb @@ -0,0 +1,49 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# PreToolUse Entrypoint +# +# This entrypoint orchestrates all PreToolUse handlers when Claude Code is about to execute a tool. +# It reads JSON input from STDIN, executes all configured handlers, merges their outputs, +# and returns the final result to Claude Code via STDOUT. + +require 'claude_hooks' +require 'json' + +# Require all PreToolUse handler classes +require_relative '../handlers/pre_tool_use_handler' + +# Add additional handler requires here as needed: +# require_relative '../handlers/pre_tool_use/security_validator' +# require_relative '../handlers/pre_tool_use/rate_limiter' +# require_relative '../handlers/pre_tool_use/audit_logger' + +begin + # Read input data from Claude Code + input_data = JSON.parse($stdin.read) + + + hook = PreToolUseHandler.new(input_data) + hook.call + hook.output_and_exit +rescue JSON::ParserError => e + warn "[PreToolUse] JSON parsing error: #{e.message}" + puts JSON.generate({ + continue: false, + decision: 'block', + reason: "PreToolUse hook JSON parsing error: #{e.message}", + suppressOutput: false + }) + exit 1 # JSON error +rescue StandardError => e + warn "[PreToolUse] Hook execution error: #{e.message}" + warn e.backtrace.join("\n") if ENV['RUBY_CLAUDE_HOOKS_DEBUG'] + + puts JSON.generate({ + continue: false, + decision: 'block', + reason: "PreToolUse hook execution error: #{e.message}", + suppressOutput: false + }) + exit 1 # General error +end diff --git a/hooks/entrypoints/session_start.rb b/hooks/entrypoints/session_start.rb new file mode 100755 index 0000000..d646652 --- /dev/null +++ b/hooks/entrypoints/session_start.rb @@ -0,0 +1,42 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# SessionStart Entrypoint +# +# This entrypoint orchestrates all SessionStart handlers when Claude Code starts a new session. +# It reads JSON input from STDIN, executes all configured handlers, merges their outputs, +# and returns the final result to Claude Code via STDOUT. + +require 'claude_hooks' +require 'json' + +# Require all SessionStart handler classes +# Add additional handler requires here as needed: +require_relative '../handlers/session_start_handler' + +begin + # Read input data from Claude Code + input_data = JSON.parse($stdin.read) + + hook = SessionStartHandler.new(input_data) + hook.call + hook.output_and_exit +rescue JSON::ParserError => e + warn "[SessionStart] JSON parsing error: #{e.message}" + puts JSON.generate({ + continue: false, + stopReason: "SessionStart hook JSON parsing error: #{e.message}", + suppressOutput: false + }) + exit 1 # JSON error +rescue StandardError => e + warn "[SessionStart] Hook execution error: #{e.message}" + warn e.backtrace.join("\n") if ENV['RUBY_CLAUDE_HOOKS_DEBUG'] + + puts JSON.generate({ + continue: false, + stopReason: "SessionStart hook execution error: #{e.message}", + suppressOutput: false + }) + exit 1 # General error +end diff --git a/hooks/entrypoints/stop.rb b/hooks/entrypoints/stop.rb new file mode 100755 index 0000000..a772752 --- /dev/null +++ b/hooks/entrypoints/stop.rb @@ -0,0 +1,49 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Stop Entrypoint +# +# This entrypoint orchestrates all Stop handlers when Claude Code finishes responding. +# It reads JSON input from STDIN, executes all configured handlers, merges their outputs, +# and returns the final result to Claude Code via STDOUT. + +require 'claude_hooks' +require 'json' + +# Require all Stop handler classes +# Add additional handler requires here as needed: +require_relative '../handlers/stop_you_are_not_right' + +begin + # Read input data from Claude Code + input_data = JSON.parse($stdin.read) + + # Initialize and execute all handlers + reflexive_agreement_handler = StopYouAreNotRight.new(input_data) + + # Execute handlers + reflexive_agreement_handler.call + + # Use single handler output + merged_output = reflexive_agreement_handler.output + + merged_output.output_and_exit +rescue JSON::ParserError => e + warn "[Stop] JSON parsing error: #{e.message}" + warn JSON.generate({ + continue: true, + stopReason: "Stop hook JSON parsing error: #{e.message}", + suppressOutput: false + }) + exit 1 +rescue StandardError => e + warn "[Stop] Hook execution error: #{e.message}" + warn e.backtrace.join("\n") if ENV['RUBY_CLAUDE_HOOKS_DEBUG'] + + warn JSON.generate({ + continue: true, + stopReason: "Stop hook execution error: #{e.message}", + suppressOutput: false + }) + exit 1 +end diff --git a/hooks/entrypoints/subagent_stop.rb b/hooks/entrypoints/subagent_stop.rb new file mode 100755 index 0000000..ba10c54 --- /dev/null +++ b/hooks/entrypoints/subagent_stop.rb @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# SubagentStop Entrypoint +# +# This entrypoint orchestrates all SubagentStop handlers when Claude Code completes a subagent task. +# It reads JSON input from STDIN, executes all configured handlers, merges their outputs, +# and returns the final result to Claude Code via STDOUT. + +require 'claude_hooks' +require 'json' + +# Require all SubagentStop handler classes +require_relative '../handlers/subagent_stop_handler' + +# Add additional handler requires here as needed: +# require_relative '../handlers/subagent_stop/result_processor' +# require_relative '../handlers/subagent_stop/performance_tracker' +# require_relative '../handlers/subagent_stop/cache_manager' + +begin + # Read input data from Claude Code + input_data = JSON.parse($stdin.read) + + + hook = SubagentStopHandler.new(input_data) + hook.call + hook.output_and_exit +rescue JSON::ParserError => e + warn "[SubagentStop] JSON parsing error: #{e.message}" + puts JSON.generate({ + continue: false, + stopReason: "SubagentStop hook JSON parsing error: #{e.message}", + suppressOutput: false + }) + exit 1 # JSON error +rescue StandardError => e + warn "[SubagentStop] Hook execution error: #{e.message}" + warn e.backtrace.join("\n") if ENV['RUBY_CLAUDE_HOOKS_DEBUG'] + + puts JSON.generate({ + continue: false, + stopReason: "SubagentStop hook execution error: #{e.message}", + suppressOutput: false + }) + exit 1 # General error +end diff --git a/hooks/entrypoints/user_prompt_submit.rb b/hooks/entrypoints/user_prompt_submit.rb new file mode 100755 index 0000000..e5af9d7 --- /dev/null +++ b/hooks/entrypoints/user_prompt_submit.rb @@ -0,0 +1,58 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# UserPromptSubmit Entrypoint +# +# This entrypoint orchestrates all UserPromptSubmit handlers when Claude Code receives a user prompt. +# It reads JSON input from STDIN, executes all configured handlers, merges their outputs, +# and returns the final result to Claude Code via STDOUT. + +require 'claude_hooks' +require 'json' + +# Require all UserPromptSubmit handler classes +# require_relative '../handlers/user_prompt_submit_handler.rb' +require_relative '../handlers/copy_message_handler' + +# Add additional handler requires here as needed: +# require_relative '../handlers/user_prompt_submit_handler' +# require_relative '../handlers/user_prompt_submit/append_rules' +# require_relative '../handlers/user_prompt_submit/log_user_prompt' +# require_relative '../handlers/user_prompt_submit/validate_content' + +begin + # Read input data from Claude Code + input_data = JSON.parse($stdin.read) + + # Initialize and execute all handlers + copy_message_handler = CopyMessageHandler.new(input_data) + + # Execute handlers + copy_message_handler.call + + # Use the single handler's output directly + merged_output = copy_message_handler.output + + # Output result and exit with appropriate code + merged_output.output_and_exit +rescue JSON::ParserError => e + warn "[UserPromptSubmit] JSON parsing error: #{e.message}" + puts JSON.generate({ + continue: false, + decision: 'block', + reason: "UserPromptSubmit hook JSON parsing error: #{e.message}", + suppressOutput: false + }) + exit 1 # JSON error +rescue StandardError => e + warn "[UserPromptSubmit] Hook execution error: #{e.message}" + warn e.backtrace.join("\n") if ENV['RUBY_CLAUDE_HOOKS_DEBUG'] + + puts JSON.generate({ + continue: false, + decision: 'block', + reason: "UserPromptSubmit hook execution error: #{e.message}", + suppressOutput: false + }) + exit 1 # General error +end diff --git a/hooks/handlers/auto_format_handler.rb b/hooks/handlers/auto_format_handler.rb new file mode 100755 index 0000000..d9bf727 --- /dev/null +++ b/hooks/handlers/auto_format_handler.rb @@ -0,0 +1,244 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'claude_hooks' +require 'open3' +require 'shellwords' + +# Auto Format Handler +# +# PURPOSE: Automatically format/lint files after Write, Edit, or MultiEdit operations +# DESIGN: Lightweight, obvious, maintainable using claude_hooks DSL patterns +# ALIGNMENT: Mirrors pre-commit configuration for consistency +# +# SUPPORTED FORMATTERS: +# - Ruby: RuboCop with auto-correct (-A flag) +# - Markdown: markdownlint with --fix (matching pre-commit rules) +# - Shell: shfmt with 2-space indentation (matching pre-commit args) +# - Lua: stylua with custom configuration (160 column width, Unix line endings, etc.) +# - Rust: rustfmt with default configuration +# - Python: ruff format (automatically uses existing Black/isort/flake8 configs) +# - YAML: yamlfmt with default configuration, prettier as fallback +# - JavaScript/TypeScript: eslint with --fix, prettier as fallback + +class AutoFormatHandler < ClaudeHooks::PostToolUse + DEFAULT_SKIP_PATTERNS = %w[ + node_modules/ + dist/ + build/ + .git/ + *.min.js + *.min.css + vendor/ + tmp/ + .bundle/ + ].freeze + + def call + log "Auto-format handler triggered for #{tool_name}" + + return output_data unless should_process_tool? + return output_data unless file_path_available? + return output_data if should_skip_file? + + perform_formatting + + output_data + end + + private + + def should_process_tool? + file_modification_tools.include?(tool_name) && tool_successful? + end + + def file_modification_tools + %w[Write Edit MultiEdit] + end + + def tool_successful? + return false if tool_response&.dig('error') + return false if tool_response&.dig('isError') + return false if tool_response&.dig('success') == false + return false if tool_response&.dig('exitCode')&.nonzero? + + true + end + + def file_path_available? + return true if current_file_path && File.exist?(current_file_path) + + log "File path not available or doesn't exist: #{current_file_path}", level: :warn + false + end + + def current_file_path + @current_file_path ||= tool_input&.dig('file_path') + end + + def should_skip_file? + return false unless current_file_path + + relative_path = relative_file_path + + # Check against simple, obvious skip patterns + if skip_patterns.any? { |pattern| matches_skip_pattern?(relative_path, pattern) } + log "Skipping #{relative_path} - matches ignore pattern" + return true + end + + false + end + + def relative_file_path + # Use claude_hooks cwd helper instead of Dir.pwd + File.expand_path(current_file_path).sub("#{cwd}/", '') + end + + def skip_patterns + DEFAULT_SKIP_PATTERNS + end + + def matches_skip_pattern?(file_path, pattern) + if pattern.end_with?('/') + # Directory pattern + file_path.start_with?(pattern[0..-2]) + elsif pattern.include?('*') + # Simple glob pattern using Ruby's built-in File.fnmatch + File.fnmatch(pattern, file_path) + else + # Exact match or basename match + file_path == pattern || File.basename(file_path) == pattern + end + end + + def perform_formatting + formatter = detect_formatter + unless formatter + log "No formatter available for #{current_file_path}" + return + end + + log "Formatting #{current_file_path} with #{formatter[:name]}" + + result = run_formatter(formatter) + + if result[:success] + log "Successfully formatted #{current_file_path}" + add_success_feedback(formatter[:name]) + else + log "Formatting failed: #{result[:error]}", level: :error + add_error_feedback(formatter[:name], result[:error]) + end + end + + def detect_formatter + extension = File.extname(current_file_path).downcase + + case extension + when '.rb' + command_available?('rubocop') ? { name: 'RuboCop', command: 'rubocop', args: ['-A'] } : nil + when '.md' + if command_available?('markdownlint') + { + name: 'markdownlint', + command: 'markdownlint', + args: [ + '--fix', + '--disable', + 'MD013,MD041,MD026,MD012,MD024' + ] + } + end + when '.sh', '.bash' + if command_available?('shfmt') + { + name: 'shfmt', + command: 'shfmt', + args: ['-w', '-i', '2'] # 2-space indentation + } + end + when '.lua' + if command_available?('stylua') + { + name: 'stylua', + command: 'stylua', + args: [ + '--column-width', '160', + '--line-endings', 'Unix', + '--indent-type', 'Spaces', + '--indent-width', '2', + '--quote-style', 'AutoPreferSingle', + '--call-parentheses', 'None' + ] + } + end + when '.rs' + command_available?('rustfmt') ? { name: 'rustfmt', command: 'rustfmt', args: [] } : nil + when '.py' + command_available?('ruff') ? { name: 'ruff', command: 'ruff', args: ['format'] } : nil + when '.yml', '.yaml' + if command_available?('yamlfmt') + { name: 'yamlfmt', command: 'yamlfmt', args: ['-w'] } + elsif command_available?('prettier') + { name: 'prettier', command: 'prettier', args: ['--write', '--parser', 'yaml'] } + end + when '.js', '.jsx', '.ts', '.tsx', '.json' + if command_available?('eslint') + { name: 'eslint', command: 'eslint', args: ['--fix'] } + elsif command_available?('prettier') + { name: 'prettier', command: 'prettier', args: ['--write'] } + end + end + end + + def command_available?(command) + # Cache command availability to avoid repeated system calls + @command_cache ||= {} + + return @command_cache[command] if @command_cache.key?(command) + + @command_cache[command] = system("which #{command} > /dev/null 2>&1") + end + + def run_formatter(formatter) + command_parts = [formatter[:command]] + formatter[:args] + [current_file_path] + command = command_parts.map { |part| Shellwords.escape(part) }.join(' ') + + log "Running: #{command}" + + stdout_err, status = Open3.capture2e(command) + + if status.success? + { success: true, output: stdout_err } + else + { success: false, error: stdout_err.strip } + end + rescue StandardError => e + { success: false, error: e.message } + end + + def add_success_feedback(formatter_name) + # Use output_data hash directly - obvious and DSL-idiomatic + output_data['feedback'] ||= [] + output_data['feedback'] << "✓ Auto-formatted with #{formatter_name}" + end + + def add_error_feedback(formatter_name, error) + output_data['feedback'] ||= [] + output_data['feedback'] << "⚠ Auto-formatting failed (#{formatter_name}): #{error}" + end +end + +# Testing support - claude_hooks DSL pattern +if __FILE__ == $PROGRAM_NAME + ClaudeHooks::CLI.test_runner(AutoFormatHandler) do |input_data| + input_data['tool_name'] = 'Write' + input_data['tool_input'] = { 'file_path' => '/tmp/test.rb' } + input_data['tool_response'] = { 'success' => true } + input_data['session_id'] = 'test-session-01' + + # Create a test file for demonstration + File.write('/tmp/test.rb', "def hello\nputs 'world'\nend") + end +end diff --git a/hooks/handlers/copy_message_handler.rb b/hooks/handlers/copy_message_handler.rb new file mode 100755 index 0000000..9f00bb6 --- /dev/null +++ b/hooks/handlers/copy_message_handler.rb @@ -0,0 +1,213 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'claude_hooks' +require 'json' +require 'rbconfig' +require_relative 'transcript_parser' + +# CopyMessageHandler +# +# PURPOSE: Handle --copy-prompt and --copy-response commands to copy messages from transcript +# TRIGGERS: When user submits a prompt starting with '--copy-prompt' or '--copy-response' +# +# COMMAND FORMATS: +# - --copy-prompt [number] - Copy last N prompts (default: 1, most recent) +# - --copy-prompt 5 - Copy the last 5 prompts +# - --copy-response [number] - Copy last N responses (default: 1, most recent) +# - --copy-response 5 - Copy the last 5 responses + +class CopyMessageHandler < ClaudeHooks::UserPromptSubmit + def call + return unless current_prompt.start_with?('--copy-prompt', '--copy-response') + + log "Processing copy command: #{current_prompt}" + + # Determine message type and parse command arguments + message_type = current_prompt.start_with?('--copy-prompt') ? 'prompt' : 'response' + args = parse_copy_command(current_prompt, message_type) + + begin + messages = extract_messages_from_transcript(message_type) + result = handle_copy_command(messages, args, message_type) + + if result[:success] + copy_to_clipboard(result[:content]) if result[:content] + block_prompt!(result[:message]) + else + log result[:error], level: :error + block_prompt!("Error: #{result[:error]}") + end + rescue StandardError => e + log "Copy #{message_type} failed: #{e.message}", level: :error + block_prompt!("Failed to copy #{message_type}: #{e.message}") + end + + output + end + + private + + def parse_copy_command(command, type) + args = { + number: 1 + } + + # Parse number from command (default to 1 if no number specified) + case command + when /^--copy-#{type}\s+(\d+)$/ + args[:number] = ::Regexp.last_match(1).to_i + end + + args + end + + def handle_copy_command(items, args, type) + return { success: false, error: "No #{type}s found" } if items.empty? + + count = args[:number] + return { success: false, error: "#{type.capitalize} count must be at least 1" } if count < 1 + + if count > items.length + return { success: false, error: "Only #{items.length} #{type}#{'s' if items.length != 1} available" } + end + + # Take the last N messages (first N from newest-first array) + selected_items = items.first(count) + # Reverse to show in chronological order (oldest to newest) + combined_content = selected_items.reverse.join("\n\n") + + message_label = count == 1 ? type.capitalize : "Last #{count} #{type}s" + preview = generate_preview(combined_content) + + { + success: true, + content: combined_content, + message: "#{message_label} copied to clipboard: #{preview}" + } + end + + def extract_messages_from_transcript(message_type) + return [] unless transcript_path && File.exist?(transcript_path) + + if message_type == 'prompt' + extract_prompts_from_transcript + else + extract_responses_from_transcript + end + end + + def extract_prompts_from_transcript + request_groups = {} + + begin + # Use defensive parser to handle variable content formats + parsed_entries = TranscriptParser.parse_transcript_file(transcript_path, strict: false) + + parsed_entries.each do |entry| + # Skip non-user messages and parse errors + next unless entry.dig(:message, :role) == 'user' + next if entry[:parse_error] + + request_id = entry[:request_id] + content = entry.dig(:message, :content) || '' + + # Group by request ID to handle multi-part prompts + if request_groups[request_id] + request_groups[request_id] += "\n#{content}" if content && !content.empty? + else + request_groups[request_id] = content + end + end + rescue TranscriptParser::TranscriptParseError => e + log "Failed to parse transcript file: #{e.message}", level: :error + return [] + rescue StandardError => e + log "Unexpected error parsing transcript: #{e.message}", level: :error + return [] + end + + # Convert to array (newest first) + request_groups.values.reverse + end + + def extract_responses_from_transcript + request_groups = {} + + begin + # Use defensive parser to handle variable content formats + parsed_entries = TranscriptParser.parse_transcript_file(transcript_path, strict: false) + + parsed_entries.each do |entry| + # Skip non-assistant messages and parse errors + next unless entry.dig(:message, :role) == 'assistant' + next if entry[:parse_error] + + request_id = entry[:request_id] + content = entry.dig(:message, :content) || '' + + # Group by request ID to handle multi-part responses + if request_groups[request_id] + request_groups[request_id] += "\n#{content}" if content && !content.empty? + else + request_groups[request_id] = content + end + end + rescue TranscriptParser::TranscriptParseError => e + log "Failed to parse transcript file: #{e.message}", level: :error + return [] + rescue StandardError => e + log "Unexpected error parsing transcript: #{e.message}", level: :error + return [] + end + + # Convert to array (newest first) + request_groups.values.reverse + end + + def generate_preview(text) + return '' if text.nil? || text.strip.empty? + + # Get first non-empty line, truncate if too long + preview = text.lines.find { |line| !line.strip.empty? }&.strip || text.strip + preview = "#{preview[0..59]}..." if preview.length > 60 + preview + end + + def copy_to_clipboard(text) + require 'open3' + + case RbConfig::CONFIG['host_os'] + when /darwin/ + Open3.popen3('pbcopy') { |stdin, _stdout, _stderr, _thread| stdin.write(text) } + when /linux/ + if system('which xclip >/dev/null 2>&1') + Open3.popen3('xclip', '-selection', 'clipboard') { |stdin, _stdout, _stderr, _thread| stdin.write(text) } + else + log 'xclip not found - clipboard copy failed', level: :error + raise 'xclip not available' + end + when /mswin|mingw|cygwin/ + # Windows - write to temp file then use PowerShell + require 'tempfile' + temp_file = Tempfile.new('claude_copy') + temp_file.write("\xEF\xBB\xBF#{text}") # UTF-8 BOM + temp_file.close + system("powershell.exe -Command \"Get-Content -Path '#{temp_file.path}' -Encoding UTF8 | Set-Clipboard\"") + temp_file.unlink + else + log 'Unsupported platform for clipboard operations', level: :error + raise 'Unsupported platform' + end + + log "Copied #{text.length} characters to clipboard" + end +end + +# Testing support - run this file directly to test with sample data +if __FILE__ == $PROGRAM_NAME + ClaudeHooks::CLI.test_runner(CopyMessageHandler) do |input_data| + input_data['prompt'] = '--copy-prompt 1' + input_data['session_id'] = 'test-session-01' + end +end diff --git a/hooks/handlers/notification_handler.rb b/hooks/handlers/notification_handler.rb new file mode 100755 index 0000000..29152cb --- /dev/null +++ b/hooks/handlers/notification_handler.rb @@ -0,0 +1,68 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'claude_hooks' + +# Basic Notification Handler +# +# PURPOSE: Handle Claude Code notifications (permission requests, idle warnings) +# TRIGGERS: When Claude needs permission to use tools or when idle for 60+ seconds +# DISABLE: Set SIMPLE_CLAUDE_DISABLE_NOTIFICATIONS=1 to skip notifications (useful when terminal handles them) + +class NotificationHandler < ClaudeHooks::Notification + def call + log "Claude Code Notification: #{message}" + + # Early return if notifications are disabled + if ENV['SIMPLE_CLAUDE_DISABLE_NOTIFICATIONS'] + log 'Notifications disabled via SIMPLE_CLAUDE_DISABLE_NOTIFICATIONS', level: :debug + return output_data + end + + # Send iOS notification popup + send_ios_notification + + # Notification hooks don't block or modify behavior, they just react + output_data + end + + private + + def send_ios_notification + title = 'Claude Code' + subtitle = case message + when /needs your permission/i + log "Permission request detected: #{message}", level: :info + 'Permission Required' + when /waiting for your input/i + log "Idle timeout detected: #{message}", level: :warn + 'Waiting for Input' + else + log "Other notification: #{message}", level: :info + 'Notification' + end + + # Use osascript to send native macOS notification + sound_arg = File.exist?(File.expand_path('~/.claude/.sounds_disabled')) ? '' : ' sound name "glass"' + command = [ + 'osascript', '-e', + %(display notification "#{escape_quotes(message)}" with title "#{title}" subtitle "#{subtitle}"#{sound_arg}) + ] + + system(*command) + rescue StandardError => e + log "Failed to send iOS notification: #{e.message}", level: :error + end + + def escape_quotes(text) + text.to_s.gsub('"', '\\"') + end +end + +# Testing support - run this file directly to test with sample data +if __FILE__ == $PROGRAM_NAME + ClaudeHooks::CLI.test_runner(NotificationHandler) do |input_data| + input_data['session_id'] = 'notification-test' + input_data['message'] = 'Claude needs your permission to use Bash' + end +end diff --git a/hooks/handlers/post_tool_use_handler.rb b/hooks/handlers/post_tool_use_handler.rb new file mode 100755 index 0000000..e040862 --- /dev/null +++ b/hooks/handlers/post_tool_use_handler.rb @@ -0,0 +1,222 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'claude_hooks' + +# PostToolUse Handler +# +# PURPOSE: Process and analyze tool execution results +# TRIGGERS: After Claude Code executes any tool (Bash, Write, Edit, etc.) +# +# COMMON USE CASES: +# - Parse and format tool output +# - Extract errors and warnings from command results +# - Update project state based on tool results +# - Log execution metrics and performance data +# - Trigger follow-up actions based on results +# - Cache or store important tool outputs +# +# SETTINGS.JSON CONFIGURATION: +# { +# "hooks": { +# "PostToolUse": [{ +# "matcher": "", +# "hooks": [{ +# "type": "command", +# "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/entrypoints/post_tool_use.rb" +# }] +# }] +# } +# } + +class PostToolUseHandler < ClaudeHooks::PostToolUse + def call + log "Processing tool result: #{tool_name}" + + # Example: Analyze tool results + # analyze_tool_results + + # Example: Extract errors and warnings + # extract_errors_and_warnings + + # Example: Update project state + # update_project_state + + # Example: Log execution metrics + # log_execution_metrics + + output_data + end + + private + + def analyze_tool_results + case tool_name + when 'Bash' + # Analyze command execution results + analyze_bash_results + + when 'Write', 'Edit', 'MultiEdit' + # Track file modifications + analyze_file_modifications + + when 'Grep', 'Glob' + # Analyze search results + analyze_search_results + end + end + + def analyze_bash_results + return unless tool_result.is_a?(Hash) + + exit_code = tool_result['exit_code'] || 0 + stdout = tool_result['stdout'] || '' + stderr = tool_result['stderr'] || '' + + if exit_code != 0 + log "Command failed with exit code: #{exit_code}", level: :error + log "stderr: #{stderr}", level: :error if stderr && !stderr.empty? + else + log 'Command executed successfully' + end + + # Example: Parse specific command outputs + command = tool_input['command'] || '' + + if command.include?('npm test') || command.include?('yarn test') + parse_test_results(stdout) + elsif command.include?('git status') + parse_git_status(stdout) + elsif command.include?('npm install') || command.include?('yarn install') + parse_install_results(stdout, stderr) + end + end + + def analyze_file_modifications + file_path = tool_input['file_path'] || '' + log "File modified: #{file_path}" + + # Example: Track important file changes + if file_path.end_with?('package.json') + log 'Package.json modified - dependencies may have changed' + elsif file_path.end_with?('.env') + log 'Environment file modified', level: :warn + elsif file_path.match?(/\.(js|ts|jsx|tsx)$/) + log 'Source code file modified' + end + end + + def analyze_search_results + return unless tool_result.is_a?(Hash) + + results = tool_result['results'] || [] + log "Search returned #{results.length} results" + + # Example: Log interesting search patterns + if results.length > 100 + log 'Large search result set - consider narrowing search', level: :warn + elsif results.empty? + log 'No search results found' + end + end + + def extract_errors_and_warnings + return unless tool_result.is_a?(Hash) + + output_text = [ + tool_result['stdout'], + tool_result['stderr'] + ].compact.join("\n") + + # Extract common error patterns + error_patterns = [ + /error:/i, + /exception:/i, + /failed:/i, + /cannot find/i, + /permission denied/i + ] + + warning_patterns = [ + /warning:/i, + /deprecated:/i, + /caution:/i + ] + + error_patterns.each do |pattern| + if output_text.match?(pattern) + log 'Error detected in tool output', level: :error + break + end + end + + warning_patterns.each do |pattern| + if output_text.match?(pattern) + log 'Warning detected in tool output', level: :warn + break + end + end + end + + def update_project_state + # Example: Update cached project information based on tool results + case tool_name + when 'Bash' + command = tool_input['command'] || '' + + if command.include?('git checkout') && tool_result['exit_code'].zero? + log 'Git branch changed - project state updated' + elsif command.include?('npm install') && tool_result['exit_code'].zero? + log 'Dependencies installed - project state updated' + end + end + end + + def log_execution_metrics + # Example: Log performance and usage metrics + return unless tool_result.is_a?(Hash) + + duration = tool_result['duration_ms'] + log "Tool execution time: #{duration}ms" if duration + + # Track resource usage + return unless tool_name == 'Bash' + + command = tool_input['command'] || '' + log "Executed command: #{command[0..50]}#{'...' if command.length > 50}" + end + + def parse_test_results(stdout) + if stdout.include?('passing') || stdout.include?('✓') + log 'Tests appear to be passing' + elsif stdout.include?('failing') || stdout.include?('✗') + log 'Tests appear to be failing', level: :warn + end + end + + def parse_git_status(stdout) + if stdout.include?('nothing to commit') + log 'Git working directory is clean' + elsif stdout.include?('Changes not staged') + log 'Unstaged changes detected' + end + end + + def parse_install_results(stdout, stderr) + if stderr&.include?('WARN') + log 'Package installation completed with warnings', level: :warn + elsif stdout&.include?('added') + log 'Packages installed successfully' + end + end +end + +# Testing support - run this file directly to test with sample data +if __FILE__ == $PROGRAM_NAME + ClaudeHooks::CLI.test_runner(PostToolUseHandler) do |input_data| + input_data['tool_name'] = 'Bash' + input_data['tool_input'] = { 'command' => 'npm test' } + input_data['tool_result'] = { 'exit_code' => 0, 'stdout' => 'All tests passing' } + input_data['session_id'] = 'test-session-01' + end +end diff --git a/hooks/handlers/pre_compact_handler.rb b/hooks/handlers/pre_compact_handler.rb new file mode 100755 index 0000000..11a870e --- /dev/null +++ b/hooks/handlers/pre_compact_handler.rb @@ -0,0 +1,357 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'claude_hooks' + +# PreCompact Handler +# +# PURPOSE: Control and customize transcript compaction before it occurs +# TRIGGERS: Before Claude Code compacts (summarizes) the conversation transcript +# +# COMMON USE CASES: +# - Preserve important conversation segments from compaction +# - Extract and save key decisions or insights before compaction +# - Backup full transcript before summarization +# - Add metadata or tags to preserve context +# - Control which parts of the conversation should be preserved +# - Generate custom summaries alongside system compaction +# +# SETTINGS.JSON CONFIGURATION: +# { +# "hooks": { +# "PreCompact": [{ +# "matcher": "", +# "hooks": [{ +# "type": "command", +# "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/entrypoints/pre_compact.rb" +# }] +# }] +# } +# } + +class PreCompactHandler < ClaudeHooks::PreCompact + def call + log "Processing pre-compaction for session: #{session_id}" + + # Example: Backup full transcript + # backup_full_transcript + + # Example: Extract key insights before compaction + # extract_key_insights + + # Example: Preserve important segments + # mark_segments_for_preservation + + # Example: Generate custom summary + # generate_custom_summary + + # Allow compaction to proceed + allow_continue! + + output_data + end + + private + + def backup_full_transcript + log 'Backing up full transcript before compaction' + + if transcript_path && File.exist?(transcript_path) + transcript_content = read_transcript + + # Example: Save to backup location + backup_path = generate_backup_path + # File.write(backup_path, transcript_content) + + log "Full transcript backed up to: #{backup_path}" + log "Transcript size: #{transcript_content.length} characters" + else + log 'No transcript found to backup', level: :warn + end + end + + def extract_key_insights + log 'Extracting key insights before compaction' + + return unless transcript_path && File.exist?(transcript_path) + + transcript = read_transcript + insights = analyze_transcript_for_insights(transcript) + + if insights.any? + log "Extracted #{insights.length} key insights" + # save_insights_to_file(insights) + + insights.each_with_index do |insight, index| + log "Insight #{index + 1}: #{insight[:type]} - #{insight[:summary][0..100]}..." + end + else + log 'No key insights found in transcript' + end + end + + def mark_segments_for_preservation + log 'Identifying segments to preserve from compaction' + + return unless transcript_path && File.exist?(transcript_path) + + transcript = read_transcript + important_segments = identify_important_segments(transcript) + + if important_segments.any? + log "Identified #{important_segments.length} important segments for preservation" + + # Example: Mark segments with special markers that compaction should preserve + important_segments.each do |segment| + log "Preserving: #{segment[:type]} at #{segment[:location]}" + end + + # save_preservation_markers(important_segments) + else + log 'No critical segments identified for preservation' + end + end + + def generate_custom_summary + log 'Generating custom summary before system compaction' + + return unless transcript_path && File.exist?(transcript_path) + + transcript = read_transcript + custom_summary = create_structured_summary(transcript) + + log "Generated custom summary with #{custom_summary.keys.length} sections" + # save_custom_summary(custom_summary) + + custom_summary.each do |section, content| + log "#{section}: #{content[:count]} items" if content.is_a?(Hash) && content[:count] + end + end + + def analyze_transcript_for_insights(transcript) + insights = [] + lines = transcript.split("\n") + + # Look for decision points + decision_patterns = [ + /decided to/i, + /chose to/i, + /selected/i, + /will use/i, + /approach will be/i + ] + + decision_patterns.each do |pattern| + lines.each_with_index do |line, index| + next unless line.match?(pattern) + + insights << { + type: 'decision', + line_number: index + 1, + content: line.strip, + summary: extract_decision_summary(line) + } + end + end + + # Look for important findings + finding_patterns = [ + /found that/i, + /discovered/i, + /identified/i, + /analysis shows/i, + /results indicate/i + ] + + finding_patterns.each do |pattern| + lines.each_with_index do |line, index| + next unless line.match?(pattern) + + insights << { + type: 'finding', + line_number: index + 1, + content: line.strip, + summary: extract_finding_summary(line) + } + end + end + + # Look for code changes + code_patterns = [ + /implemented/i, + /added function/i, + /created class/i, + /modified/i, + /refactored/i + ] + + code_patterns.each do |pattern| + lines.each_with_index do |line, index| + next unless line.match?(pattern) + + insights << { + type: 'code_change', + line_number: index + 1, + content: line.strip, + summary: extract_code_change_summary(line) + } + end + end + + insights + end + + def identify_important_segments(transcript) + segments = [] + lines = transcript.split("\n") + + # Identify error resolution segments + error_context = [] + in_error_resolution = false + + lines.each_with_index do |line, index| + if line.match?(/error|exception|failed/i) + in_error_resolution = true + error_context = [index] + elsif in_error_resolution && line.match?(/fixed|resolved|solved|working/i) + error_context << index + segments << { + type: 'error_resolution', + location: "lines #{error_context.first}-#{error_context.last}", + start_line: error_context.first, + end_line: error_context.last, + summary: 'Error resolution sequence' + } + in_error_resolution = false + error_context = [] + end + + # Identify architectural decisions + next unless line.match?(/architecture|design pattern|structure|approach/i) + + segments << { + type: 'architectural_decision', + location: "line #{index + 1}", + line_number: index + 1, + content: line.strip, + summary: 'Architectural decision or discussion' + } + + # Identify code review segments + next unless line.match?(/review|feedback|suggestion|improvement/i) + + segments << { + type: 'code_review', + location: "line #{index + 1}", + line_number: index + 1, + content: line.strip, + summary: 'Code review or improvement suggestion' + } + end + + segments + end + + def create_structured_summary(transcript) + lines = transcript.split("\n") + + { + session_info: { + session_id: session_id, + line_count: lines.length, + character_count: transcript.length, + compaction_time: Time.now + }, + tools_used: extract_tools_used(lines), + files_modified: extract_files_modified(lines), + key_decisions: extract_decisions(lines), + errors_and_resolutions: extract_error_resolutions(lines), + user_goals: extract_user_goals(lines) + } + end + + def extract_tools_used(lines) + tools = [] + lines.each do |line| + if (match = line.match(/using (\w+) tool/i) || line.match(/tool: (\w+)/i)) + tools << match[1] + end + end + { tools: tools.uniq, count: tools.uniq.length } + end + + def extract_files_modified(lines) + files = [] + lines.each do |line| + if (match = line.match(/modified file: (.+)/i) || line.match(/editing (.+\.\w+)/i)) + files << match[1].strip + end + end + { files: files.uniq, count: files.uniq.length } + end + + def extract_decisions(lines) + decisions = [] + lines.each_with_index do |line, index| + next unless line.match?(/decided|chose|selected|will use/i) + + decisions << { + line: index + 1, + summary: line.strip[0..100] + (line.length > 100 ? '...' : '') + } + end + { decisions: decisions, count: decisions.length } + end + + def extract_error_resolutions(lines) + resolutions = [] + lines.each_with_index do |line, index| + next unless line.match?(/fixed|resolved|solved/i) && + lines[(index - 1)..(index + 1)].any? { |l| l.match?(/error|exception/i) } + + resolutions << { + line: index + 1, + summary: line.strip[0..100] + (line.length > 100 ? '...' : '') + } + end + { resolutions: resolutions, count: resolutions.length } + end + + def extract_user_goals(lines) + goals = [] + lines.each_with_index do |line, index| + next unless line.match?(/^user:|^User:/i) && line.match?(/help me|I need|can you|please/i) + + goals << { + line: index + 1, + summary: line.strip[0..150] + (line.length > 150 ? '...' : '') + } + end + { goals: goals, count: goals.length } + end + + def generate_backup_path + timestamp = Time.now.strftime('%Y%m%d_%H%M%S') + project_path_for("backups/transcripts/#{session_id}_#{timestamp}.txt") + end + + def extract_decision_summary(line) + line.strip[0..100] + (line.length > 100 ? '...' : '') + end + + def extract_finding_summary(line) + line.strip[0..100] + (line.length > 100 ? '...' : '') + end + + def extract_code_change_summary(line) + line.strip[0..100] + (line.length > 100 ? '...' : '') + end +end + +# Testing support - run this file directly to test with sample data +if __FILE__ == $PROGRAM_NAME + ClaudeHooks::CLI.test_runner(PreCompactHandler) do |input_data| + input_data['session_id'] = 'test-session-01' + input_data['reason'] = 'transcript_size_limit' + end +end diff --git a/hooks/handlers/pre_tool_use_handler.rb b/hooks/handlers/pre_tool_use_handler.rb new file mode 100755 index 0000000..6362a8a --- /dev/null +++ b/hooks/handlers/pre_tool_use_handler.rb @@ -0,0 +1,150 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'claude_hooks' + +# PreToolUse Handler +# +# PURPOSE: Control and validate tool usage before execution +# TRIGGERS: Before Claude Code executes any tool (Bash, Write, Edit, etc.) +# +# COMMON USE CASES: +# - Block dangerous commands (rm -rf, chmod 777, etc.) +# - Require approval for sensitive operations +# - Log tool usage for security auditing +# - Apply rate limiting or usage quotas +# - Validate file paths and permissions +# - Add safety checks for system commands +# +# SETTINGS.JSON CONFIGURATION: +# { +# "hooks": { +# "PreToolUse": [{ +# "matcher": "", +# "hooks": [{ +# "type": "command", +# "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/entrypoints/pre_tool_use.rb" +# }] +# }] +# } +# } + +class PreToolUseHandler < ClaudeHooks::PreToolUse + def call + log "Checking tool usage: #{tool_name} with input: #{tool_input}" + + # Example: Block dangerous commands + # check_dangerous_commands + + # Example: Validate file operations + # validate_file_operations + + # Example: Apply rate limiting + # check_rate_limits + + # Example: Log tool usage + # log_tool_usage + + # Default: approve the tool usage + approve_tool!('Tool usage approved') + + output_data + end + + private + + def check_dangerous_commands + case tool_name + when 'Bash' + command = tool_input['command'] || '' + + dangerous_patterns = [ + %r{rm\s+-rf\s+/}, # rm -rf / + /chmod\s+777/, # chmod 777 + /sudo\s+rm/, # sudo rm + %r{>\s*/dev/sd[a-z]}, # writing to disk devices + /mkfs\./, # filesystem creation + %r{dd\s+if=.*of=/dev} # disk imaging to devices + ] + + dangerous_patterns.each do |pattern| + next unless command.match?(pattern) + + block_tool!("Dangerous command detected: #{pattern}") + log "Blocked dangerous command: #{command}", level: :error + return + end + + when 'Write', 'Edit' + file_path = tool_input['file_path'] || '' + + # Block writes to system files + if file_path.start_with?('/etc/', '/usr/', '/bin/', '/sbin/') + ask_for_permission!("Attempting to modify system file: #{file_path}") + nil + end + end + end + + def validate_file_operations + return unless %w[Write Edit MultiEdit].include?(tool_name) + + file_path = tool_input['file_path'] || '' + + # Ensure file path is within project directory + current_dir = cwd || Dir.pwd + unless file_path.start_with?(current_dir) + log "File operation outside project directory: #{file_path}", level: :warn + ask_for_permission!('File operation outside project directory') + return + end + + # Check for sensitive files + sensitive_files = [ + '.env', + '.env.local', + 'config/secrets.yml', + 'private_key', + 'id_rsa' + ] + + return unless sensitive_files.any? { |f| file_path.include?(f) } + + ask_for_permission!("Modifying potentially sensitive file: #{File.basename(file_path)}") + nil + end + + def check_rate_limits + # Example: Implement rate limiting for expensive operations + return unless tool_name == 'Bash' + + command = tool_input['command'] || '' + + # Limit compilation commands + return unless command.match?(/gcc|g\+\+|clang|rustc|go build/) + + log 'Compilation command detected, checking rate limits' + # Could implement actual rate limiting logic here + end + + def log_tool_usage + log "Tool: #{tool_name}" + log "Session: #{session_id}" + log "Working directory: #{cwd || Dir.pwd}" + + # Log tool input (be careful with sensitive data) + return unless tool_input.is_a?(Hash) + + sanitized_input = tool_input.reject { |k, _v| k.to_s.downcase.include?('password') } + log "Input keys: #{sanitized_input.keys.join(', ')}" + end +end + +# Testing support - run this file directly to test with sample data +if __FILE__ == $PROGRAM_NAME + ClaudeHooks::CLI.test_runner(PreToolUseHandler) do |input_data| + input_data['tool_name'] = 'Bash' + input_data['tool_input'] = { 'command' => 'ls -la' } + input_data['session_id'] = 'test-session-01' + end +end diff --git a/hooks/handlers/reflexive_agreement_detector.rb b/hooks/handlers/reflexive_agreement_detector.rb new file mode 100755 index 0000000..1b7fd1a --- /dev/null +++ b/hooks/handlers/reflexive_agreement_detector.rb @@ -0,0 +1,96 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Shared detection logic for reflexive agreement patterns +# Used by both the stop hook and test harness to ensure consistency +module ReflexiveAgreementDetector + # Agreement patterns - match at start of first sentence + AGREEMENT_PATTERNS = [ + /\A\s*[Yy]ou'?re?\s+(absolutely\s+)?(right|correct)/i, + /\A\s*[Aa]bsolutely\.?\s*\Z/i, + /\A\s*[Yy]es,?\s+you'?re?\s+(totally\s+)?correct/i, + /\A\s*[Dd]efinitely\.?\s*\Z/i + ].freeze + + # Pivot words that indicate substantive continuation + PIVOT_INDICATORS = /\b(but|however|though|although|that said|except|yet)\b/i + + # Substantive content markers - require CONCRETE technical evidence only + # Removed: "here's why", "the reason", "for example", "because" (setup words, not substance) + # Removed: "found|discovered|detected" (can be vacuous: "I found the issue" without details) + SUBSTANTIVE_MARKERS = [ + /\d+\s*(slots|tokens|ms|bytes|chars|seconds|lines|files|functions|tests|errors?|warnings?)/i, # Specific measurements + /```[\s\S]{20,}```/, # Code blocks (min 20 chars) + /\b(benchmark|profiled|traced|debugged|stack trace|error message)\b/i # Technical investigation + ].freeze + + # Main detection method + # Returns true if message is reflexive agreement (should trigger) + def reflexive_agreement?(message) + text = extract_text(message) + return false unless text.is_a?(String) + + # STEP 1: Check if first sentence contains agreement + first_sentence = extract_first_sentence(text) + return false unless first_sentence_has_agreement?(first_sentence) + + # STEP 2: Check if response contains tool use + return false if message_contains_tool_use?(message) + + # STEP 3: Check for pivot and substantive content + has_pivot = first_sentence_has_pivot?(first_sentence) + has_substantive = followed_by_substantive_content?(text) + + # Pivot words only matter if backed by substantive follow-up + return false if has_pivot && has_substantive + + # Substantive content without pivot = still not reflexive + return false if has_substantive + + # At this point: agreement detected, no tool use, no substantive follow-up + true + end + + private + + def extract_text(message) + message.dig('message', 'content', 0, 'text') + end + + def extract_first_sentence(text) + sentences = text.split(/[.!?]+/) + first = sentences[0] || '' + first.strip + end + + def first_sentence_has_agreement?(sentence) + AGREEMENT_PATTERNS.any? { |pattern| sentence.match?(pattern) } + end + + def message_contains_tool_use?(message) + content = message.dig('message', 'content') || [] + content.any? { |block| block['type'] == 'tool_use' } + end + + def first_sentence_has_pivot?(sentence) + # Only count actual pivots and colons/dashes as setup for substantive content + # Removed: "need to|will|should|must|going to|have to" (action promises, not pivots) + return true if sentence.match?(PIVOT_INDICATORS) + return true if sentence.match?(/[:\u2014\u2013]\s*$/) + + false + end + + def followed_by_substantive_content?(text) + sentences = text.split(/[.!?]+/).map(&:strip).reject(&:empty?) + return false if sentences.length <= 1 + + rest_of_text = sentences[1..].join(' ') + + # Require SIGNIFICANT follow-up with concrete technical content + # Increased from 50 → 100 chars minimum (rules out "Let me check what you have") + return false if rest_of_text.length < 100 + + SUBSTANTIVE_MARKERS.any? { |marker| rest_of_text.match?(marker) } + end +end diff --git a/hooks/handlers/session_start_handler.rb b/hooks/handlers/session_start_handler.rb new file mode 100755 index 0000000..11f2405 --- /dev/null +++ b/hooks/handlers/session_start_handler.rb @@ -0,0 +1,84 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'date' +require 'claude_hooks' + +# SessionStart Handler +# +# PURPOSE: Initialize session state, setup logging, prepare environment +# TRIGGERS: When a new Claude Code session begins +# +# SETTINGS.JSON CONFIGURATION: +# { +# "hooks": { +# "SessionStart": [{ +# "matcher": "", +# "hooks": [{ +# "type": "command", +# "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/entrypoints/session_start.rb" +# }] +# }] +# } +# } + +class SessionStartHandler < ClaudeHooks::SessionStart + def call + log "Session starting for project: #{project_name}" + + backup_projects_directory + acknowledge_current_date + + allow_continue! + suppress_output! + + output_data + end + + private + + def project_name + File.basename(cwd || Dir.pwd) + end + + def backup_projects_directory + source_dir = File.expand_path('~/.claude/projects') + backup_dir = File.expand_path('~/backups/claude/projects') + + if Dir.exist?(source_dir) + log 'Backing up projects directory to ~/backups/claude/projects' + + # Create backup directory if it doesn't exist + system('mkdir', '-p', backup_dir) + + # Remove existing backup and copy fresh + system('rm', '-rf', backup_dir) if Dir.exist?(backup_dir) + + # Copy the projects directory + if system('cp', '-r', source_dir, backup_dir) + log 'Projects directory backup completed successfully' + else + log 'Warning: Projects directory backup failed' + end + else + log 'No projects directory found to backup' + end + end + + def acknowledge_current_date + # Add timestamp to backend context only + current_time = Time.now.strftime('%B %d, %Y at %I:%M %p %Z') + day_of_week = Date.today.strftime('%A') + + # Use additionalContext for Claude instructions (will be minimally visible) + context_message = "Current local date and time: #{day_of_week}, #{current_time}" + add_additional_context!("#{context_message}. Acknowledge the current date and time in your first response.") + end +end + +# Testing support - run this file directly to test with sample data +if __FILE__ == $PROGRAM_NAME + ClaudeHooks::CLI.test_runner(SessionStartHandler) do |input_data| + input_data['session_id'] = 'test-session-01' + end +end diff --git a/hooks/handlers/stop_handler.rb b/hooks/handlers/stop_handler.rb new file mode 100755 index 0000000..57494c5 --- /dev/null +++ b/hooks/handlers/stop_handler.rb @@ -0,0 +1,234 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'claude_hooks' + +# Stop Handler +# +# PURPOSE: Control and customize Claude Code's stopping behavior +# TRIGGERS: When Claude Code is about to stop execution or end a session +# +# COMMON USE CASES: +# - Save session state and cleanup resources +# - Generate session summaries or reports +# - Backup important work or temporary files +# - Log session metrics and analytics +# - Send completion notifications +# - Trigger follow-up actions or workflows +# +# SETTINGS.JSON CONFIGURATION: +# { +# "hooks": { +# "Stop": [{ +# "matcher": "", +# "hooks": [{ +# "type": "command", +# "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/entrypoints/stop.rb" +# }] +# }] +# } +# } + +class StopHandler < ClaudeHooks::Stop + def call + log 'Processing session stop request' + + # Example: Save session state + # save_session_state + + # Example: Generate session summary + # generate_session_summary + + # Example: Cleanup temporary resources + # cleanup_resources + + # Example: Send completion notifications + # send_completion_notifications + + # Allow stopping to proceed + allow_continue! + + output_data + end + + private + + def save_session_state + log 'Saving session state' + + # Example: Save important session data + { + session_id: session_id, + end_time: Time.now, + working_directory: cwd || Dir.pwd, + transcript_path: transcript_path + } + + # Example: Write to session cache + # write_session_cache(session_data) + + # Example: Update project metadata + # update_project_metadata(session_data) + end + + def generate_session_summary + log 'Generating session summary' + + # Example: Read transcript and generate summary + return unless transcript_path && File.exist?(transcript_path) + + transcript = read_transcript + summary = analyze_transcript(transcript) + + log "Session summary: #{summary[:total_interactions]} interactions, #{summary[:tools_used].length} tools used" + + # Example: Save summary to file + # save_session_summary(summary) + end + + def cleanup_resources + log 'Cleaning up session resources' + + # Example: Remove temporary files + # cleanup_temp_files + + # Example: Close database connections + # close_database_connections + + # Example: Clear caches + # clear_session_caches + + # Example: Stop background processes + # stop_background_processes + end + + def send_completion_notifications + log 'Sending session completion notifications' + + # Example: Notify team about session completion + # notify_team_completion + + # Example: Send analytics data + # send_analytics_data + + # Example: Update project dashboard + # update_project_dashboard + end + + def analyze_transcript(transcript) + # Simple transcript analysis + lines = transcript.split("\n") + + { + total_lines: lines.length, + total_interactions: count_user_interactions(lines), + tools_used: extract_tools_used(lines), + errors_encountered: count_errors(lines), + session_duration: calculate_session_duration(lines) + } + end + + def count_user_interactions(lines) + # Count lines that look like user messages + lines.count { |line| line.match?(/^(user:|User:)/i) } + end + + def extract_tools_used(lines) + # Extract tool names from transcript + tools = [] + + lines.each do |line| + tools << ::Regexp.last_match(1) if line.match?(/Using tool: (\w+)/) || line.match?(/Tool call: (\w+)/) + end + + tools.uniq + end + + def count_errors(lines) + # Count error messages in transcript + error_patterns = [ + /error:/i, + /failed:/i, + /exception:/i, + /cannot/i + ] + + lines.count do |line| + error_patterns.any? { |pattern| line.match?(pattern) } + end + end + + def calculate_session_duration(lines) + # Simple duration calculation based on first and last timestamps + first_timestamp = extract_first_timestamp(lines) + last_timestamp = extract_last_timestamp(lines) + + if first_timestamp && last_timestamp + (last_timestamp - first_timestamp).to_i + else + 0 + end + end + + def extract_first_timestamp(_lines) + # Extract timestamp from first line (implementation depends on transcript format) + # This is a placeholder - actual implementation would depend on transcript format + nil + end + + def extract_last_timestamp(_lines) + # Extract timestamp from last line (implementation depends on transcript format) + # This is a placeholder - actual implementation would depend on transcript format + nil + end + + def write_session_cache(data) + # Example: Write session data to cache file + project_path_for('cache/sessions') + # FileUtils.mkdir_p(cache_dir) + # File.write("#{cache_dir}/#{session_id}.json", JSON.pretty_generate(data)) + log "Would write session cache: #{data.keys.join(', ')}" + end + + def update_project_metadata(_data) + # Example: Update project-level metadata + log 'Would update project metadata with session data' + end + + def save_session_summary(summary) + # Example: Save session summary to reports directory + project_path_for('reports/sessions') + # FileUtils.mkdir_p(reports_dir) + # File.write("#{reports_dir}/#{session_id}_summary.json", JSON.pretty_generate(summary)) + log "Would save session summary: #{summary.keys.join(', ')}" + end + + def cleanup_temp_files + # Example: Remove temporary files created during session + temp_patterns = [ + project_path_for('tmp/**/*'), + project_path_for('.temp/**/*'), + project_path_for('*.tmp') + ] + + log "Would cleanup temporary files matching patterns: #{temp_patterns.join(', ')}" + end + + def notify_team_completion + # Example: Send Slack/Teams notification about session completion + log 'Would notify team about session completion' + end + + def send_analytics_data + # Example: Send session metrics to analytics service + log 'Would send analytics data for session' + end +end + +# Testing support - run this file directly to test with sample data +if __FILE__ == $PROGRAM_NAME + ClaudeHooks::CLI.test_runner(StopHandler) do |input_data| + input_data['session_id'] = 'test-session-01' + input_data['reason'] = 'user_requested' + end +end diff --git a/hooks/handlers/stop_you_are_not_right.rb b/hooks/handlers/stop_you_are_not_right.rb new file mode 100755 index 0000000..093cc76 --- /dev/null +++ b/hooks/handlers/stop_you_are_not_right.rb @@ -0,0 +1,116 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'claude_hooks' +require 'json' +require_relative 'reflexive_agreement_detector' + +# Stop Hook for Real-Time Reflexive Agreement Correction +# +# PURPOSE: Detect reflexive agreement in Claude's just-completed response +# TRIGGERS: When Claude Code finishes generating a response +# ACTION: Force continuation with corrective instructions if reflexive agreement detected +# +# DETECTION STRATEGY: +# Reflexive agreement = agreement phrase in first sentence WITHOUT research/substantive work +# - Checks first sentence for agreement patterns +# - Verifies no tool use in response (no Read, Grep, WebFetch, etc.) +# - Checks for substantive pivots or follow-up analysis + +class StopYouAreNotRight < ClaudeHooks::Stop + include ReflexiveAgreementDetector + + def call + log 'Checking if Claude just used reflexive agreement in response' + + if just_used_reflexive_agreement? + log 'Detected reflexive agreement - forcing corrective continuation', level: :warn + force_substantive_response! + else + log 'No reflexive agreement detected - allowing normal stop' + allow_normal_stop! + end + + output + end + + private + + def just_used_reflexive_agreement? + return false unless transcript_path + + # Get Claude's most recent message (the one that just completed) + last_message = get_last_assistant_message + return false unless last_message + + text = extract_text(last_message) + return false unless text.is_a?(String) + + log "Analyzing response: '#{text[0, 100]}...'" + + # Use shared detection logic + is_reflexive = reflexive_agreement?(last_message) + + if is_reflexive + first_sentence = extract_first_sentence(text) + log "Reflexive agreement detected: '#{first_sentence}'" + else + log "Not reflexive agreement" + end + + is_reflexive + end + + def get_last_assistant_message + return nil unless File.exist?(transcript_path) + + # Read transcript in reverse to find the most recent assistant message + assistant_message = nil + + File.readlines(transcript_path).reverse_each do |line| + next unless line.include?('"role":"assistant"') + + begin + item = JSON.parse(line.strip) + next unless item['type'] == 'assistant' + + # Get full message including all content blocks + assistant_message = item + break # Found the most recent one + rescue JSON::ParserError + next + end + end + + assistant_message + end + + def force_substantive_response! + correction_instructions = <<~INSTRUCTIONS.strip + I notice I just used a reflexive agreement phrase. Let me provide a more substantive response: + + Instead 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. + INSTRUCTIONS + + # Force Claude to continue with corrective instructions + continue_with_instructions!(correction_instructions) + + log 'Forced continuation with corrective instructions' + end + + def allow_normal_stop! + # Allow Claude to stop normally (default behavior) + ensure_stopping! + + # Don't add any output to the transcript for normal stops + suppress_output! + end +end + +# Testing support - run this file directly to test with sample data +if __FILE__ == $PROGRAM_NAME + ClaudeHooks::CLI.test_runner(StopYouAreNotRight) do |input_data| + input_data['session_id'] = 'stop-correction-test' + input_data['stop_hook_active'] = false + end +end diff --git a/hooks/handlers/subagent_stop_handler.rb b/hooks/handlers/subagent_stop_handler.rb new file mode 100755 index 0000000..ba1b2dc --- /dev/null +++ b/hooks/handlers/subagent_stop_handler.rb @@ -0,0 +1,61 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'claude_hooks' + +# SubagentStop Handler +# +# PURPOSE: Process and enhance results from completed subagent tasks +# TRIGGERS: When a subagent (spawned via Task tool) completes execution +# +# COMMON USE CASES: +# - Extract and cache key insights from subagent results +# - Log subagent performance metrics +# - Process and transform subagent outputs +# - Trigger follow-up actions based on subagent results +# - Aggregate results from multiple subagent executions +# - Update project state based on subagent findings +# +# SETTINGS.JSON CONFIGURATION: +# { +# "hooks": { +# "SubagentStop": [{ +# "matcher": "", +# "hooks": [{ +# "type": "command", +# "command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/subagent_stop.rb" +# }] +# }] +# } +# } + +class SubagentStopHandler < ClaudeHooks::SubagentStop + def call + log "Processing subagent completion for session: #{session_id}" + + # Example: Extract key insights from subagent result + # extract_subagent_insights + + # Example: Cache subagent results for reuse + # cache_subagent_results + + # Example: Log subagent performance metrics + # log_subagent_metrics + + # Example: Trigger follow-up actions + # trigger_follow_up_actions + + # Allow subagent stop to proceed normally + allow_continue! + + output_data + end +end + +# Testing support - run this file directly to test with sample data +if __FILE__ == $PROGRAM_NAME + ClaudeHooks::CLI.test_runner(SubagentStopHandler) do |input_data| + input_data['session_id'] = 'test-subagent-01' + input_data['subagent_type'] = 'code-explorer' + end +end diff --git a/hooks/handlers/transcript_parser.rb b/hooks/handlers/transcript_parser.rb new file mode 100755 index 0000000..afe4a12 --- /dev/null +++ b/hooks/handlers/transcript_parser.rb @@ -0,0 +1,235 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# TranscriptParser - Defensive JSON parsing utilities for Claude Code transcripts +# +# PURPOSE: Provide safe, robust parsing of Claude Code transcript JSON data +# HANDLES: Variable content formats (String vs Array), malformed data, type validation +# +# KEY FEATURES: +# - Defensive type checking before processing +# - Graceful degradation for unexpected formats +# - Comprehensive error handling with meaningful messages +# - Ruby best practices for type validation +# - Extensible for future transcript format changes + +module TranscriptParser + # Custom errors for specific parsing scenarios + class TranscriptParseError < StandardError; end + class MalformedTranscriptError < TranscriptParseError; end + class UnsupportedContentFormatError < TranscriptParseError; end + class MissingRequiredFieldError < TranscriptParseError; end + + # Main entry point for parsing transcript JSON data + # @param data [Hash] The parsed JSON data from transcript + # @param strict [Boolean] Whether to raise errors on malformed data (default: false) + # @return [Hash] Normalized data structure with validated content + def self.parse_transcript_entry(data, strict: false) + validate_transcript_structure!(data) + + result = { + request_id: extract_request_id(data), + timestamp: extract_timestamp(data), + message: parse_message(data['message'], strict: strict) + } + + # Add optional fields if present + result[:tool_name] = data['toolName'] if data.key?('toolName') + result[:tool_input] = data['toolInput'] if data.key?('toolInput') + result[:tool_response] = data['toolResponse'] if data.key?('toolResponse') + + result + rescue StandardError => e + raise TranscriptParseError, "Failed to parse transcript entry: #{e.message}" if strict + + # Return safe fallback structure + { + request_id: data['requestId'] || 'unknown', + timestamp: data['timestamp'] || Time.now.to_f, + message: { role: 'unknown', content: '', type: 'fallback' }, + parse_error: e.message + } + end + + # Extract and normalize message content with defensive type handling + # @param message [Hash] The message object from transcript data + # @param strict [Boolean] Whether to raise errors on unsupported formats + # @return [Hash] Normalized message with extracted content + def self.parse_message(message, strict: false) + validate_message_structure!(message) + + role = extract_role(message) + content = extract_content(message['content'], strict: strict) + + { + role: role, + content: content[:text], + content_type: content[:type], + raw_content: message['content'] # Preserve original for debugging + } + end + + # Safely extract text content from variable format content field + # @param content_data [String, Array, Object] The content field from message + # @param strict [Boolean] Whether to raise errors on unsupported formats + # @return [Hash] Hash with :text and :type keys + def self.extract_content(content_data, strict: false) + case content_data + when String + { text: content_data, type: 'string' } + when Array + { text: extract_content_from_array(content_data), type: 'array' } + when Hash + { text: extract_content_from_hash(content_data), type: 'hash' } + when nil + { text: '', type: 'nil' } + else + if strict + raise UnsupportedContentFormatError, + "Unsupported content format: #{content_data.class} - #{content_data.inspect}" + else + { text: content_data.to_s, type: 'converted' } + end + end + end + + # Extract text from array-based content (Claude's structured message format) + # @param content_array [Array] Array of content blocks + # @return [String] Concatenated text content + def self.extract_content_from_array(content_array) + return '' unless content_array.is_a?(Array) + + content_array.filter_map do |block| + case block + when Hash + # Skip tool_use entries as they don't contain displayable text + next if block['type'] == 'tool_use' || block[:type] == 'tool_use' + + # Handle structured content blocks (e.g., {type: 'text', text: 'content'}) + block['text'] || block[:text] || block.dig('content', 'text') || + block.values.find { |v| v.is_a?(String) && v.length.positive? } + when String + block + else + block.to_s if block.respond_to?(:to_s) + end + end.join('') + end + + # Extract text from hash-based content + # @param content_hash [Hash] Hash containing content data + # @return [String] Extracted text content + def self.extract_content_from_hash(content_hash) + return '' unless content_hash.is_a?(Hash) + + # Try common content keys in order of preference + %w[text content message body data value].each do |key| + return content_hash[key] if content_hash.key?(key) && content_hash[key].is_a?(String) + end + + # Fallback: convert whole hash to string for debugging + content_hash.to_s + end + + # Validate basic transcript entry structure + # @param data [Hash] The transcript entry data + # @raise [MalformedTranscriptError] If required fields are missing + def self.validate_transcript_structure!(data) + raise MalformedTranscriptError, 'Data must be a Hash' unless data.is_a?(Hash) + + return if data.key?('message') + + raise MissingRequiredFieldError, "Missing required 'message' field" + end + + # Validate message structure + # @param message [Hash] The message object + # @raise [MalformedTranscriptError] If message structure is invalid + def self.validate_message_structure!(message) + raise MalformedTranscriptError, 'Message must be a Hash' unless message.is_a?(Hash) + + raise MissingRequiredFieldError, "Missing required 'role' field in message" unless message.key?('role') + + return if message.key?('content') + + raise MissingRequiredFieldError, "Missing required 'content' field in message" + end + + # Extract request ID with fallback + # @param data [Hash] The transcript entry data + # @return [String] Request ID or generated fallback + def self.extract_request_id(data) + data['requestId'] || data['request_id'] || "req_#{Time.now.to_f}" + end + + # Extract timestamp with fallback + # @param data [Hash] The transcript entry data + # @return [Float] Timestamp or current time + def self.extract_timestamp(data) + timestamp = data['timestamp'] || data['created_at'] || Time.now.to_f + timestamp.is_a?(Numeric) ? timestamp : Time.now.to_f + end + + # Extract role with validation + # @param message [Hash] The message object + # @return [String] The role (user, assistant, system) + def self.extract_role(message) + role = message['role'] || message[:role] + return role if %w[user assistant system].include?(role) + + # Fallback role detection + 'unknown' + end + + # Batch process multiple transcript entries with error isolation + # @param lines [Array] Array of JSON strings from transcript file + # @param strict [Boolean] Whether to raise errors on malformed entries + # @return [Array] Array of parsed entries, with errors isolated + def self.parse_transcript_lines(lines, strict: false) + results = [] + + lines.each_with_index do |line, index| + next if line.nil? || line.strip.empty? + + begin + data = JSON.parse(line.strip) + results << parse_transcript_entry(data, strict: strict) + rescue JSON::ParserError => e + error_entry = { + line_number: index + 1, + parse_error: "JSON parse error: #{e.message}", + raw_line: line.strip[0..100] + (line.length > 100 ? '...' : ''), + message: { role: 'error', content: '', type: 'json_error' } + } + + raise TranscriptParseError, "JSON parse error on line #{index + 1}: #{e.message}" if strict + + results << error_entry + rescue StandardError => e + error_entry = { + line_number: index + 1, + parse_error: "Unexpected error: #{e.message}", + raw_line: line.strip[0..100] + (line.length > 100 ? '...' : ''), + message: { role: 'error', content: '', type: 'parse_error' } + } + + raise TranscriptParseError, "Parse error on line #{index + 1}: #{e.message}" if strict + + results << error_entry + end + end + + results + end + + # Helper method to safely read and parse transcript file + # @param file_path [String] Path to transcript file + # @param strict [Boolean] Whether to raise errors on malformed data + # @return [Array] Array of parsed transcript entries + def self.parse_transcript_file(file_path, strict: false) + raise TranscriptParseError, "Transcript file not found: #{file_path}" unless File.exist?(file_path) + + lines = File.readlines(file_path, chomp: true) + parse_transcript_lines(lines, strict: strict) + end +end diff --git a/hooks/handlers/user_prompt_submit_handler.rb b/hooks/handlers/user_prompt_submit_handler.rb new file mode 100755 index 0000000..2eb73b6 --- /dev/null +++ b/hooks/handlers/user_prompt_submit_handler.rb @@ -0,0 +1,103 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'claude_hooks' + +# UserPromptSubmit Handler +# +# PURPOSE: Modify, validate, or enhance user prompts before processing +# TRIGGERS: When user submits a prompt to Claude Code +# +# COMMON USE CASES: +# - Add context rules or project-specific instructions +# - Validate prompt content (block inappropriate requests) +# - Append relevant documentation or code examples +# - Transform or rewrite prompts for better results +# - Log user interactions for analytics +# - Apply prompt templates or formatting +# +# SETTINGS.JSON CONFIGURATION: +# { +# "hooks": { +# "UserPromptSubmit": [{ +# "matcher": "", +# "hooks": [{ +# "type": "command", +# "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/entrypoints/user_prompt_submit.rb" +# }] +# }] +# } +# } + +class UserPromptSubmitHandler < ClaudeHooks::UserPromptSubmit + def call + log "Processing user prompt: #{prompt[0..100]}..." + + # Example: Add project context + # add_project_context + + # Example: Validate prompt content + # validate_prompt_content + + # Example: Apply prompt transformations + # transform_prompt + + # Example: Log the interaction + # log_user_interaction + + output_data + end + + private + + def add_project_context + # Example: Add project-specific rules or context + context_file = project_path_for('rules/project-context.md') + + return unless File.exist?(context_file) + + context = File.read(context_file) + add_additional_context!(context) + log "Added project context (#{context.length} characters)" + end + + def validate_prompt_content + # Example: Block prompts that contain sensitive patterns + sensitive_patterns = [ + /api[_\s]?key/i, + /password/i, + /secret/i + ] + + sensitive_patterns.each do |pattern| + next unless prompt.match?(pattern) + + block_prompt!('Prompt may contain sensitive information') + log "Blocked prompt containing sensitive pattern: #{pattern}", level: :warn + return + end + end + + def transform_prompt + # Example: Add helpful formatting or structure + return unless prompt.downcase.include?('help me implement') + + add_additional_context!( + 'Please provide step-by-step implementation guidance and consider edge cases.' + ) + end + + def log_user_interaction + # Example: Log prompt for analytics (be careful with privacy) + log "User prompt length: #{prompt.length} characters" + log "Session: #{session_id}" + end +end + +# Testing support - run this file directly to test with sample data +if __FILE__ == $PROGRAM_NAME + ClaudeHooks::CLI.test_runner(UserPromptSubmitHandler) do |input_data| + input_data['prompt'] = 'Help me implement a new feature' + input_data['session_id'] = 'test-session-01' + end +end diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..01ad41e --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,87 @@ +{ + "description": "SimpleClaude hook configuration for session management, tool monitoring, and notifications", + "hooks": { + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/notification.rb" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/post_tool_use.rb" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/pre_tool_use.rb" + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/pre_compact.rb" + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/session_start.rb" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/stop.rb" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/subagent_stop.rb" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/entrypoints/user_prompt_submit.rb" + } + ] + } + ] + } +} diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..7884c4f --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,129 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:kylesnowschwartz/SimpleClaude:plugins/sc-hooks", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "5fe25b7cc83a4a991c0e8d137f7c251a2c1938ac", + "treeHash": "760d5543d67006f44ba3e73debea79e735e7bb148f272472a06dfcbd25db6c8f", + "generatedAt": "2025-11-28T10:20:01.432546Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "sc-hooks", + "description": "SimpleClaude hooks for session management, tool monitoring, and notifications", + "version": "2.1.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "95c6402103e3741dc0fc4aef24a8c57b5bd6028584ae7e8011f80b2d956e4be4" + }, + { + "path": "hooks/hooks.json", + "sha256": "78ece61e05bcc81e2a87d35f6bb1008ff7584502f7a32826945cd753a04d05b4" + }, + { + "path": "hooks/entrypoints/user_prompt_submit.rb", + "sha256": "abaa32b66e90d6073f4447717c431d2353a0bb624365e9161ed48efff564818d" + }, + { + "path": "hooks/entrypoints/session_start.rb", + "sha256": "11c88164c21a3fffaf881a14f1897e3c47945bd6ade8c7d3e0e69d83de8c81e5" + }, + { + "path": "hooks/entrypoints/subagent_stop.rb", + "sha256": "167c4d38cc4f0f6c919739975147739eeeddea6fe5c1ea0b82c5ca384d997367" + }, + { + "path": "hooks/entrypoints/pre_compact.rb", + "sha256": "2c117c162f67ddc472d955c1fa3ff61917afcd1df47fcf728540a5e34ee032a1" + }, + { + "path": "hooks/entrypoints/notification.rb", + "sha256": "78e5082fd4db088136db9ffcbe17e0d479727005fd4faa625925bc125b835469" + }, + { + "path": "hooks/entrypoints/stop.rb", + "sha256": "054bb96e2642c53894ed5f68a1740a99ea8f71ee52b3c3fb1cb4029990b6e902" + }, + { + "path": "hooks/entrypoints/pre_tool_use.rb", + "sha256": "8d39cc426329190db777f4209e72a4f5a4b6bca0d24f58f9d577b31cb5f1c1d9" + }, + { + "path": "hooks/entrypoints/post_tool_use.rb", + "sha256": "bf26701b132da73344313658a5eb4584afefcf9578ec681c2cca9546e0dbf35f" + }, + { + "path": "hooks/handlers/transcript_parser.rb", + "sha256": "544466f527649e731049ffe431e212a5a6a2eee1be6fc6a0f08258929445b811" + }, + { + "path": "hooks/handlers/copy_message_handler.rb", + "sha256": "19119d2e5c64e94a70853c29e0e1d3acc8bf0a991cee2ed0106d77ad9d80c606" + }, + { + "path": "hooks/handlers/pre_compact_handler.rb", + "sha256": "496a82713960fe786bf2b375a815d09bcb5192fe847f79852d0689e68f8a76e9" + }, + { + "path": "hooks/handlers/reflexive_agreement_detector.rb", + "sha256": "c8637c4a281075a96d8f9faecaeec06a48522664b035fd85d81b84c2f0c565d0" + }, + { + "path": "hooks/handlers/pre_tool_use_handler.rb", + "sha256": "f507e20f890bbb98153396dc29eee0e54e35351b5c7e86c04d6284314e5d7cf6" + }, + { + "path": "hooks/handlers/subagent_stop_handler.rb", + "sha256": "fd4c2fee2401fd66d591f6b5701ffef0f2a49f7bc8b7f662e1c0ff7aaf20ca41" + }, + { + "path": "hooks/handlers/post_tool_use_handler.rb", + "sha256": "73f3800723d0e2c6b5b46e8ba06fb2cd0dbda0ae09a2807ac6df796f0dcf67fa" + }, + { + "path": "hooks/handlers/auto_format_handler.rb", + "sha256": "414cdcab2b79962ab81deea652a87013d92b8d790f382062e747a534175386c6" + }, + { + "path": "hooks/handlers/notification_handler.rb", + "sha256": "c8e25e21e78eeaa6d94d663fa914e7dcaba687296cc0e7b4877db5adbf8316d6" + }, + { + "path": "hooks/handlers/stop_handler.rb", + "sha256": "0f58e7fa10505bbd1ce4f2777e8f11ffc3a3647a7fc3737396dbf5596df3df5d" + }, + { + "path": "hooks/handlers/session_start_handler.rb", + "sha256": "a649e0cb3a8e9d9098289feeed0ffdc7a583f79c791d057043e0bc8ad41b47ce" + }, + { + "path": "hooks/handlers/stop_you_are_not_right.rb", + "sha256": "4f7d542e6585f0f2532cf94613e0c4febd93480948afa99e5ab781b30ed58906" + }, + { + "path": "hooks/handlers/user_prompt_submit_handler.rb", + "sha256": "b0b1578db595664b5eac5258ea1920be5ff4e20e9b0cad07713076d51855b160" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "74944816a1daf8e50598c573a7833206383ea12bcafd6dffe68b5d3222d6e97e" + } + ], + "dirSha256": "760d5543d67006f44ba3e73debea79e735e7bb148f272472a06dfcbd25db6c8f" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file