Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:36:27 +08:00
commit 328d222e29
25 changed files with 2802 additions and 0 deletions

View File

@@ -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"
]
}

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# sc-hooks
SimpleClaude hooks for session management, tool monitoring, and notifications

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

49
hooks/entrypoints/stop.rb Executable file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 '<empty>' 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

234
hooks/handlers/stop_handler.rb Executable file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<String>] Array of JSON strings from transcript file
# @param strict [Boolean] Whether to raise errors on malformed entries
# @return [Array<Hash>] 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<Hash>] 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

View File

@@ -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

87
hooks/hooks.json Normal file
View File

@@ -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"
}
]
}
]
}
}

129
plugin.lock.json Normal file
View File

@@ -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": []
}
}