claude_hooks 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/skills/ci-monitoring/SKILL.md +103 -0
  3. data/.agents/skills/run-tests/SKILL.md +45 -0
  4. data/.claude/auto-fix.md +7 -0
  5. data/.claude/settings.local.json +21 -0
  6. data/AGENTS.md +46 -0
  7. data/CHANGELOG.md +93 -0
  8. data/README.md +159 -21
  9. data/docs/1.1.0_UPGRADE_GUIDE.md +269 -0
  10. data/docs/API/COMMON.md +7 -0
  11. data/docs/API/CONFIG_CHANGE.md +39 -0
  12. data/docs/API/CWD_CHANGED.md +34 -0
  13. data/docs/API/ELICITATION.md +44 -0
  14. data/docs/API/ELICITATION_RESULT.md +43 -0
  15. data/docs/API/FILE_CHANGED.md +37 -0
  16. data/docs/API/INSTRUCTIONS_LOADED.md +30 -0
  17. data/docs/API/MESSAGE_DISPLAY.md +38 -0
  18. data/docs/API/NOTIFICATION.md +1 -0
  19. data/docs/API/PERMISSION_DENIED.md +37 -0
  20. data/docs/API/PERMISSION_REQUEST.md +199 -0
  21. data/docs/API/POST_COMPACT.md +30 -0
  22. data/docs/API/POST_TOOL_BATCH.md +39 -0
  23. data/docs/API/POST_TOOL_USE.md +1 -0
  24. data/docs/API/POST_TOOL_USE_FAILURE.md +31 -0
  25. data/docs/API/PRE_COMPACT.md +10 -2
  26. data/docs/API/PRE_TOOL_USE.md +9 -1
  27. data/docs/API/SETUP.md +37 -0
  28. data/docs/API/STOP_FAILURE.md +19 -0
  29. data/docs/API/SUBAGENT_START.md +40 -0
  30. data/docs/API/TASK_COMPLETED.md +43 -0
  31. data/docs/API/TASK_CREATED.md +43 -0
  32. data/docs/API/TEAMMATE_IDLE.md +40 -0
  33. data/docs/API/USER_PROMPT_EXPANSION.md +43 -0
  34. data/docs/API/WORKTREE_CREATE.md +39 -0
  35. data/docs/API/WORKTREE_REMOVE.md +33 -0
  36. data/docs/PROMPT_BASED_HOOKS.md +386 -0
  37. data/docs/external/claude-hooks-reference.md +2846 -18
  38. data/docs/mitts/ideabox.md +12 -0
  39. data/docs/mitts/prd.md +417 -0
  40. data/docs/mitts/setup.md +303 -0
  41. data/docs/mitts/task.md +44 -0
  42. data/lib/claude_hooks/base.rb +30 -1
  43. data/lib/claude_hooks/config_change.rb +28 -0
  44. data/lib/claude_hooks/cwd_changed.rb +28 -0
  45. data/lib/claude_hooks/elicitation.rb +61 -0
  46. data/lib/claude_hooks/elicitation_result.rb +57 -0
  47. data/lib/claude_hooks/file_changed.rb +41 -0
  48. data/lib/claude_hooks/instructions_loaded.rb +23 -0
  49. data/lib/claude_hooks/message_display.rb +58 -0
  50. data/lib/claude_hooks/notification.rb +5 -1
  51. data/lib/claude_hooks/output/base.rb +48 -1
  52. data/lib/claude_hooks/output/config_change.rb +47 -0
  53. data/lib/claude_hooks/output/cwd_changed.rb +42 -0
  54. data/lib/claude_hooks/output/elicitation.rb +67 -0
  55. data/lib/claude_hooks/output/elicitation_result.rb +63 -0
  56. data/lib/claude_hooks/output/file_changed.rb +42 -0
  57. data/lib/claude_hooks/output/instructions_loaded.rb +19 -0
  58. data/lib/claude_hooks/output/message_display.rb +49 -0
  59. data/lib/claude_hooks/output/permission_denied.rb +42 -0
  60. data/lib/claude_hooks/output/permission_request.rb +120 -0
  61. data/lib/claude_hooks/output/post_compact.rb +18 -0
  62. data/lib/claude_hooks/output/post_tool_batch.rb +51 -0
  63. data/lib/claude_hooks/output/post_tool_use.rb +12 -0
  64. data/lib/claude_hooks/output/post_tool_use_failure.rb +42 -0
  65. data/lib/claude_hooks/output/pre_compact.rb +34 -3
  66. data/lib/claude_hooks/output/pre_tool_use.rb +26 -2
  67. data/lib/claude_hooks/output/session_start.rb +30 -8
  68. data/lib/claude_hooks/output/setup.rb +42 -0
  69. data/lib/claude_hooks/output/stop.rb +15 -0
  70. data/lib/claude_hooks/output/stop_failure.rb +19 -0
  71. data/lib/claude_hooks/output/subagent_start.rb +42 -0
  72. data/lib/claude_hooks/output/task_completed.rb +18 -0
  73. data/lib/claude_hooks/output/task_created.rb +18 -0
  74. data/lib/claude_hooks/output/teammate_idle.rb +18 -0
  75. data/lib/claude_hooks/output/user_prompt_expansion.rb +51 -0
  76. data/lib/claude_hooks/output/worktree_create.rb +60 -0
  77. data/lib/claude_hooks/output/worktree_remove.rb +19 -0
  78. data/lib/claude_hooks/permission_denied.rb +41 -0
  79. data/lib/claude_hooks/permission_request.rb +63 -0
  80. data/lib/claude_hooks/post_compact.rb +23 -0
  81. data/lib/claude_hooks/post_tool_batch.rb +59 -0
  82. data/lib/claude_hooks/post_tool_use.rb +15 -1
  83. data/lib/claude_hooks/post_tool_use_failure.rb +45 -0
  84. data/lib/claude_hooks/pre_compact.rb +7 -0
  85. data/lib/claude_hooks/pre_tool_use.rb +34 -1
  86. data/lib/claude_hooks/session_start.rb +28 -0
  87. data/lib/claude_hooks/setup.rb +25 -0
  88. data/lib/claude_hooks/stop.rb +17 -0
  89. data/lib/claude_hooks/stop_failure.rb +27 -0
  90. data/lib/claude_hooks/subagent_start.rb +23 -0
  91. data/lib/claude_hooks/subagent_stop.rb +5 -1
  92. data/lib/claude_hooks/task_completed.rb +35 -0
  93. data/lib/claude_hooks/task_created.rb +35 -0
  94. data/lib/claude_hooks/teammate_idle.rb +23 -0
  95. data/lib/claude_hooks/user_prompt_expansion.rb +45 -0
  96. data/lib/claude_hooks/version.rb +1 -1
  97. data/lib/claude_hooks/worktree_create.rb +27 -0
  98. data/lib/claude_hooks/worktree_remove.rb +19 -0
  99. data/lib/claude_hooks.rb +42 -0
  100. metadata +76 -2
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class PostToolBatch < Base
8
+ def decision
9
+ @data['decision']
10
+ end
11
+
12
+ def reason
13
+ @data['reason'] || ''
14
+ end
15
+
16
+ def blocked?
17
+ decision == 'block'
18
+ end
19
+
20
+ def additional_context
21
+ hook_specific_output['additionalContext'] || ''
22
+ end
23
+
24
+ def exit_code
25
+ default_exit_code
26
+ end
27
+
28
+ def self.merge(*outputs)
29
+ compacted_outputs = outputs.compact
30
+ return compacted_outputs.first if compacted_outputs.length == 1
31
+ return super(*outputs) if compacted_outputs.empty?
32
+
33
+ merged = super(*outputs)
34
+ merged_data = merged.data
35
+ reasons = []
36
+
37
+ compacted_outputs.each do |output|
38
+ output_data = output.respond_to?(:data) ? output.data : output
39
+ if output_data['decision'] == 'block'
40
+ merged_data['decision'] = 'block'
41
+ r = output_data['reason']
42
+ reasons << r if r && !r.empty?
43
+ end
44
+ end
45
+
46
+ merged_data['reason'] = reasons.join('; ') if reasons.any?
47
+ new(merged_data)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -19,12 +19,24 @@ module ClaudeHooks
19
19
  hook_specific_output['additionalContext'] || ''
20
20
  end
21
21
 
22
+ def updated_tool_output
23
+ hook_specific_output['updatedToolOutput']
24
+ end
25
+
26
+ def updated_mcp_tool_output
27
+ hook_specific_output['updatedMCPToolOutput']
28
+ end
29
+
22
30
  # === SEMANTIC HELPERS ===
23
31
 
24
32
  def blocked?
25
33
  decision == 'block'
26
34
  end
27
35
 
36
+ def output_updated?
37
+ !updated_tool_output.nil? || !updated_mcp_tool_output.nil?
38
+ end
39
+
28
40
  # === EXIT CODE LOGIC ===
29
41
  #
30
42
  # PostToolUse hooks use the advanced JSON API with exit code 0.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class PostToolUseFailure < Base
8
+ def additional_context
9
+ hook_specific_output['additionalContext'] || ''
10
+ end
11
+
12
+ def exit_code
13
+ default_exit_code
14
+ end
15
+
16
+ def self.merge(*outputs)
17
+ compacted_outputs = outputs.compact
18
+ return compacted_outputs.first if compacted_outputs.length == 1
19
+ return super(*outputs) if compacted_outputs.empty?
20
+
21
+ merged = super(*outputs)
22
+ merged_data = merged.data
23
+ contexts = []
24
+
25
+ compacted_outputs.each do |output|
26
+ output_data = output.respond_to?(:data) ? output.data : output
27
+ context = output_data.dig('hookSpecificOutput', 'additionalContext')
28
+ contexts << context if context && !context.empty?
29
+ end
30
+
31
+ unless contexts.empty?
32
+ merged_data['hookSpecificOutput'] = {
33
+ 'hookEventName' => 'PostToolUseFailure',
34
+ 'additionalContext' => contexts.join("\n\n")
35
+ }
36
+ end
37
+
38
+ new(merged_data)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -5,6 +5,20 @@ require_relative 'base'
5
5
  module ClaudeHooks
6
6
  module Output
7
7
  class PreCompact < Base
8
+ # === DECISION ACCESSORS ===
9
+
10
+ def decision
11
+ @data['decision']
12
+ end
13
+
14
+ def reason
15
+ @data['reason'] || ''
16
+ end
17
+
18
+ def blocked?
19
+ decision == 'block'
20
+ end
21
+
8
22
  def exit_code
9
23
  default_exit_code
10
24
  end
@@ -12,9 +26,26 @@ module ClaudeHooks
12
26
  # === MERGE HELPER ===
13
27
 
14
28
  def self.merge(*outputs)
29
+ compacted_outputs = outputs.compact
30
+ return compacted_outputs.first if compacted_outputs.length == 1
31
+ return super(*outputs) if compacted_outputs.empty?
32
+
15
33
  merged = super(*outputs)
16
- new(merged.data)
17
- end
34
+ merged_data = merged.data
35
+ reasons = []
36
+
37
+ compacted_outputs.each do |output|
38
+ output_data = output.respond_to?(:data) ? output.data : output
39
+ if output_data['decision'] == 'block'
40
+ merged_data['decision'] = 'block'
41
+ r = output_data['reason']
42
+ reasons << r if r && !r.empty?
43
+ end
44
+ end
45
+
46
+ merged_data['reason'] = reasons.join('; ') if reasons.any?
47
+ new(merged_data)
48
+ end
18
49
  end
19
50
  end
20
- end
51
+ end
@@ -15,6 +15,10 @@ module ClaudeHooks
15
15
  hook_specific_output['permissionDecisionReason'] || ''
16
16
  end
17
17
 
18
+ def updated_input
19
+ hook_specific_output['updatedInput']
20
+ end
21
+
18
22
  # === SEMANTIC HELPERS ===
19
23
 
20
24
  def allowed?
@@ -30,6 +34,14 @@ module ClaudeHooks
30
34
  permission_decision == 'ask'
31
35
  end
32
36
 
37
+ def input_updated?
38
+ !updated_input.nil?
39
+ end
40
+
41
+ def deferred?
42
+ permission_decision == 'defer'
43
+ end
44
+
33
45
  # === EXIT CODE LOGIC ===
34
46
  #
35
47
  # PreToolUse hooks use the advanced JSON API with exit code 0.
@@ -58,7 +70,7 @@ module ClaudeHooks
58
70
  merged = super(*outputs)
59
71
  merged_data = merged.data
60
72
 
61
- # PreToolUse specific merge: deny > ask > allow (most restrictive wins)
73
+ # PreToolUse specific merge: deny > defer > ask > allow (most restrictive wins)
62
74
  permission_decision = 'allow'
63
75
  permission_reasons = []
64
76
 
@@ -71,8 +83,10 @@ module ClaudeHooks
71
83
  case current_decision
72
84
  when 'deny'
73
85
  permission_decision = 'deny'
86
+ when 'defer'
87
+ permission_decision = 'defer' unless permission_decision == 'deny'
74
88
  when 'ask'
75
- permission_decision = 'ask' unless permission_decision == 'deny'
89
+ permission_decision = 'ask' unless %w[deny defer].include?(permission_decision)
76
90
  end
77
91
 
78
92
  reason = output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
@@ -87,6 +101,16 @@ module ClaudeHooks
87
101
  ''
88
102
  end
89
103
 
104
+ # If any output has updatedInput, use the last one (most recent wins)
105
+ updated_inputs = []
106
+ compacted_outputs.each do |output|
107
+ output_data = output.respond_to?(:data) ? output.data : output
108
+ updated = output_data.dig('hookSpecificOutput', 'updatedInput')
109
+ updated_inputs << updated if updated
110
+ end
111
+
112
+ merged_data['hookSpecificOutput']['updatedInput'] = updated_inputs.last if updated_inputs.any?
113
+
90
114
  new(merged_data)
91
115
  end
92
116
  end
@@ -11,6 +11,22 @@ module ClaudeHooks
11
11
  hook_specific_output['additionalContext'] || ''
12
12
  end
13
13
 
14
+ def session_title
15
+ hook_specific_output['sessionTitle']
16
+ end
17
+
18
+ def initial_user_message
19
+ hook_specific_output['initialUserMessage']
20
+ end
21
+
22
+ def watch_paths
23
+ hook_specific_output['watchPaths'] || []
24
+ end
25
+
26
+ def reload_skills?
27
+ hook_specific_output['reloadSkills'] == true
28
+ end
29
+
14
30
  # === EXIT CODE LOGIC ===
15
31
 
16
32
  def exit_code
@@ -27,19 +43,25 @@ module ClaudeHooks
27
43
  merged = super(*outputs)
28
44
  merged_data = merged.data
29
45
  contexts = []
30
-
46
+ specific = {}
47
+
31
48
  compacted_outputs.each do |output|
32
49
  output_data = output.respond_to?(:data) ? output.data : output
33
- context = output_data.dig('hookSpecificOutput', 'additionalContext')
50
+ hso = output_data['hookSpecificOutput'] || {}
51
+
52
+ context = hso['additionalContext']
34
53
  contexts << context if context && !context.empty?
54
+
55
+ # Last-non-nil wins for the scalar/array session fields.
56
+ %w[sessionTitle initialUserMessage watchPaths reloadSkills].each do |key|
57
+ specific[key] = hso[key] unless hso[key].nil?
58
+ end
35
59
  end
36
60
 
37
- # Set merged additional context
38
- unless contexts.empty?
39
- merged_data['hookSpecificOutput'] = {
40
- 'hookEventName' => 'SessionStart',
41
- 'additionalContext' => contexts.join("\n\n")
42
- }
61
+ specific['additionalContext'] = contexts.join("\n\n") unless contexts.empty?
62
+
63
+ unless specific.empty?
64
+ merged_data['hookSpecificOutput'] = { 'hookEventName' => 'SessionStart' }.merge(specific)
43
65
  end
44
66
 
45
67
  new(merged_data)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class Setup < Base
8
+ def additional_context
9
+ hook_specific_output['additionalContext'] || ''
10
+ end
11
+
12
+ def exit_code
13
+ default_exit_code
14
+ end
15
+
16
+ def self.merge(*outputs)
17
+ compacted_outputs = outputs.compact
18
+ return compacted_outputs.first if compacted_outputs.length == 1
19
+ return super(*outputs) if compacted_outputs.empty?
20
+
21
+ merged = super(*outputs)
22
+ merged_data = merged.data
23
+ contexts = []
24
+
25
+ compacted_outputs.each do |output|
26
+ output_data = output.respond_to?(:data) ? output.data : output
27
+ context = output_data.dig('hookSpecificOutput', 'additionalContext')
28
+ contexts << context if context && !context.empty?
29
+ end
30
+
31
+ unless contexts.empty?
32
+ merged_data['hookSpecificOutput'] = {
33
+ 'hookEventName' => 'Setup',
34
+ 'additionalContext' => contexts.join("\n\n")
35
+ }
36
+ end
37
+
38
+ new(merged_data)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -18,6 +18,10 @@ module ClaudeHooks
18
18
  end
19
19
  alias_method :continue_instructions, :reason
20
20
 
21
+ def additional_context
22
+ hook_specific_output['additionalContext'] || ''
23
+ end
24
+
21
25
  # === SEMANTIC HELPERS ===
22
26
 
23
27
  # Check if Claude should be forced to continue (decision == 'block')
@@ -61,6 +65,7 @@ module ClaudeHooks
61
65
 
62
66
  # A blocking reason is actually a "continue instructions"
63
67
  blocking_reasons = []
68
+ contexts = []
64
69
 
65
70
  compacted_outputs.each do |output|
66
71
  output_data = output.respond_to?(:data) ? output.data : output
@@ -71,11 +76,21 @@ module ClaudeHooks
71
76
  reason = output_data['reason']
72
77
  blocking_reasons << reason if reason && !reason.empty?
73
78
  end
79
+
80
+ context = output_data.dig('hookSpecificOutput', 'additionalContext')
81
+ contexts << context if context && !context.empty?
74
82
  end
75
83
 
76
84
  # Combine all blocking reasons / continue instructions
77
85
  merged_data['reason'] = blocking_reasons.join('; ') unless blocking_reasons.empty?
78
86
 
87
+ unless contexts.empty?
88
+ merged_data['hookSpecificOutput'] = {
89
+ 'hookEventName' => merged_data.dig('hookSpecificOutput', 'hookEventName') || 'Stop',
90
+ 'additionalContext' => contexts.join("\n\n")
91
+ }
92
+ end
93
+
79
94
  new(merged_data)
80
95
  end
81
96
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ # StopFailure is purely a logging event — output/exit is ignored by Claude Code.
8
+ class StopFailure < Base
9
+ def exit_code
10
+ 0
11
+ end
12
+
13
+ def self.merge(*outputs)
14
+ merged = super(*outputs)
15
+ new(merged.data)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class SubagentStart < Base
8
+ def additional_context
9
+ hook_specific_output['additionalContext'] || ''
10
+ end
11
+
12
+ def exit_code
13
+ default_exit_code
14
+ end
15
+
16
+ def self.merge(*outputs)
17
+ compacted_outputs = outputs.compact
18
+ return compacted_outputs.first if compacted_outputs.length == 1
19
+ return super(*outputs) if compacted_outputs.empty?
20
+
21
+ merged = super(*outputs)
22
+ merged_data = merged.data
23
+ contexts = []
24
+
25
+ compacted_outputs.each do |output|
26
+ output_data = output.respond_to?(:data) ? output.data : output
27
+ context = output_data.dig('hookSpecificOutput', 'additionalContext')
28
+ contexts << context if context && !context.empty?
29
+ end
30
+
31
+ unless contexts.empty?
32
+ merged_data['hookSpecificOutput'] = {
33
+ 'hookEventName' => 'SubagentStart',
34
+ 'additionalContext' => contexts.join("\n\n")
35
+ }
36
+ end
37
+
38
+ new(merged_data)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class TaskCompleted < Base
8
+ def exit_code
9
+ default_exit_code
10
+ end
11
+
12
+ def self.merge(*outputs)
13
+ merged = super(*outputs)
14
+ new(merged.data)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class TaskCreated < Base
8
+ def exit_code
9
+ default_exit_code
10
+ end
11
+
12
+ def self.merge(*outputs)
13
+ merged = super(*outputs)
14
+ new(merged.data)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class TeammateIdle < Base
8
+ def exit_code
9
+ default_exit_code
10
+ end
11
+
12
+ def self.merge(*outputs)
13
+ merged = super(*outputs)
14
+ new(merged.data)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class UserPromptExpansion < Base
8
+ def decision
9
+ @data['decision']
10
+ end
11
+
12
+ def reason
13
+ @data['reason'] || ''
14
+ end
15
+
16
+ def blocked?
17
+ decision == 'block'
18
+ end
19
+
20
+ def additional_context
21
+ hook_specific_output['additionalContext'] || ''
22
+ end
23
+
24
+ def exit_code
25
+ default_exit_code
26
+ end
27
+
28
+ def self.merge(*outputs)
29
+ compacted_outputs = outputs.compact
30
+ return compacted_outputs.first if compacted_outputs.length == 1
31
+ return super(*outputs) if compacted_outputs.empty?
32
+
33
+ merged = super(*outputs)
34
+ merged_data = merged.data
35
+ reasons = []
36
+
37
+ compacted_outputs.each do |output|
38
+ output_data = output.respond_to?(:data) ? output.data : output
39
+ if output_data['decision'] == 'block'
40
+ merged_data['decision'] = 'block'
41
+ r = output_data['reason']
42
+ reasons << r if r && !r.empty?
43
+ end
44
+ end
45
+
46
+ merged_data['reason'] = reasons.join('; ') if reasons.any?
47
+ new(merged_data)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ # WorktreeCreate has a special bare-stdout contract:
8
+ # the last non-empty stdout line is interpreted as the worktree path.
9
+ # An empty/missing path causes Claude Code to treat creation as failed.
10
+ class WorktreeCreate < Base
11
+ def worktree_path
12
+ @data['hookSpecificOutput']&.dig('worktreePath') || @data['_worktree_path']
13
+ end
14
+
15
+ def exit_code
16
+ worktree_path && !worktree_path.empty? ? 0 : 1
17
+ end
18
+
19
+ def output_stream
20
+ :stdout
21
+ end
22
+
23
+ # Overrides the default JSON output — prints the bare path instead.
24
+ def output_and_exit
25
+ path = worktree_path
26
+ if path && !path.empty?
27
+ $stdout.puts path
28
+ exit 0
29
+ else
30
+ exit 1
31
+ end
32
+ end
33
+
34
+ def self.merge(*outputs)
35
+ compacted_outputs = outputs.compact
36
+ return compacted_outputs.first if compacted_outputs.length == 1
37
+ return super(*outputs) if compacted_outputs.empty?
38
+
39
+ # Last set path wins
40
+ merged = super(*outputs)
41
+ merged_data = merged.data
42
+ last_path = nil
43
+
44
+ compacted_outputs.each do |output|
45
+ output_data = output.respond_to?(:data) ? output.data : output
46
+ p = output_data.dig('hookSpecificOutput', 'worktreePath') || output_data['_worktree_path']
47
+ last_path = p if p && !p.empty?
48
+ end
49
+
50
+ if last_path
51
+ merged_data['hookSpecificOutput'] ||= { 'hookEventName' => 'WorktreeCreate' }
52
+ merged_data['hookSpecificOutput']['worktreePath'] = last_path
53
+ merged_data['_worktree_path'] = last_path
54
+ end
55
+
56
+ new(merged_data)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ # WorktreeRemove is non-blocking — only suppressOutput is honored.
8
+ class WorktreeRemove < Base
9
+ def exit_code
10
+ 0
11
+ end
12
+
13
+ def self.merge(*outputs)
14
+ merged = super(*outputs)
15
+ new(merged.data)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ class PermissionDenied < Base
7
+ def self.hook_type
8
+ 'PermissionDenied'
9
+ end
10
+
11
+ def self.input_fields
12
+ %w[tool_name tool_input tool_use_id reason]
13
+ end
14
+
15
+ def tool_name
16
+ @input_data['tool_name']
17
+ end
18
+
19
+ def tool_input
20
+ @input_data['tool_input']
21
+ end
22
+
23
+ def tool_use_id
24
+ @input_data['tool_use_id'] || @input_data['toolUseId']
25
+ end
26
+
27
+ def reason
28
+ @input_data['reason']
29
+ end
30
+
31
+ def retry!
32
+ @output_data['hookSpecificOutput'] ||= { 'hookEventName' => hook_event_name }
33
+ @output_data['hookSpecificOutput']['retry'] = true
34
+ end
35
+
36
+ def no_retry!
37
+ @output_data['hookSpecificOutput'] ||= { 'hookEventName' => hook_event_name }
38
+ @output_data['hookSpecificOutput']['retry'] = false
39
+ end
40
+ end
41
+ end