Initial commit
This commit is contained in:
11
.claude-plugin/plugin.json
Normal file
11
.claude-plugin/plugin.json
Normal 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
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# sc-hooks
|
||||
|
||||
SimpleClaude hooks for session management, tool monitoring, and notifications
|
||||
50
hooks/entrypoints/notification.rb
Executable file
50
hooks/entrypoints/notification.rb
Executable 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
|
||||
47
hooks/entrypoints/post_tool_use.rb
Executable file
47
hooks/entrypoints/post_tool_use.rb
Executable 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
|
||||
47
hooks/entrypoints/pre_compact.rb
Executable file
47
hooks/entrypoints/pre_compact.rb
Executable 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
|
||||
49
hooks/entrypoints/pre_tool_use.rb
Executable file
49
hooks/entrypoints/pre_tool_use.rb
Executable 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
|
||||
42
hooks/entrypoints/session_start.rb
Executable file
42
hooks/entrypoints/session_start.rb
Executable 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
49
hooks/entrypoints/stop.rb
Executable 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
|
||||
47
hooks/entrypoints/subagent_stop.rb
Executable file
47
hooks/entrypoints/subagent_stop.rb
Executable 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
|
||||
58
hooks/entrypoints/user_prompt_submit.rb
Executable file
58
hooks/entrypoints/user_prompt_submit.rb
Executable 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
|
||||
244
hooks/handlers/auto_format_handler.rb
Executable file
244
hooks/handlers/auto_format_handler.rb
Executable 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
|
||||
213
hooks/handlers/copy_message_handler.rb
Executable file
213
hooks/handlers/copy_message_handler.rb
Executable 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
|
||||
68
hooks/handlers/notification_handler.rb
Executable file
68
hooks/handlers/notification_handler.rb
Executable 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
|
||||
222
hooks/handlers/post_tool_use_handler.rb
Executable file
222
hooks/handlers/post_tool_use_handler.rb
Executable 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
|
||||
357
hooks/handlers/pre_compact_handler.rb
Executable file
357
hooks/handlers/pre_compact_handler.rb
Executable 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
|
||||
150
hooks/handlers/pre_tool_use_handler.rb
Executable file
150
hooks/handlers/pre_tool_use_handler.rb
Executable 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
|
||||
96
hooks/handlers/reflexive_agreement_detector.rb
Executable file
96
hooks/handlers/reflexive_agreement_detector.rb
Executable 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
|
||||
84
hooks/handlers/session_start_handler.rb
Executable file
84
hooks/handlers/session_start_handler.rb
Executable 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
234
hooks/handlers/stop_handler.rb
Executable 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
|
||||
116
hooks/handlers/stop_you_are_not_right.rb
Executable file
116
hooks/handlers/stop_you_are_not_right.rb
Executable 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
|
||||
61
hooks/handlers/subagent_stop_handler.rb
Executable file
61
hooks/handlers/subagent_stop_handler.rb
Executable 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
|
||||
235
hooks/handlers/transcript_parser.rb
Executable file
235
hooks/handlers/transcript_parser.rb
Executable 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
|
||||
103
hooks/handlers/user_prompt_submit_handler.rb
Executable file
103
hooks/handlers/user_prompt_submit_handler.rb
Executable 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
87
hooks/hooks.json
Normal 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
129
plugin.lock.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user