claude_hooks 1.1.0 → 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 (95) 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/settings.local.json +21 -0
  5. data/AGENTS.md +46 -0
  6. data/CHANGELOG.md +43 -0
  7. data/README.md +156 -21
  8. data/docs/API/COMMON.md +7 -1
  9. data/docs/API/CONFIG_CHANGE.md +39 -0
  10. data/docs/API/CWD_CHANGED.md +34 -0
  11. data/docs/API/ELICITATION.md +44 -0
  12. data/docs/API/ELICITATION_RESULT.md +43 -0
  13. data/docs/API/FILE_CHANGED.md +37 -0
  14. data/docs/API/INSTRUCTIONS_LOADED.md +30 -0
  15. data/docs/API/MESSAGE_DISPLAY.md +38 -0
  16. data/docs/API/PERMISSION_DENIED.md +37 -0
  17. data/docs/API/PERMISSION_REQUEST.md +11 -8
  18. data/docs/API/POST_COMPACT.md +30 -0
  19. data/docs/API/POST_TOOL_BATCH.md +39 -0
  20. data/docs/API/POST_TOOL_USE_FAILURE.md +31 -0
  21. data/docs/API/PRE_COMPACT.md +10 -2
  22. data/docs/API/PRE_TOOL_USE.md +5 -1
  23. data/docs/API/SETUP.md +37 -0
  24. data/docs/API/STOP_FAILURE.md +19 -0
  25. data/docs/API/SUBAGENT_START.md +40 -0
  26. data/docs/API/TASK_COMPLETED.md +43 -0
  27. data/docs/API/TASK_CREATED.md +43 -0
  28. data/docs/API/TEAMMATE_IDLE.md +40 -0
  29. data/docs/API/USER_PROMPT_EXPANSION.md +43 -0
  30. data/docs/API/WORKTREE_CREATE.md +39 -0
  31. data/docs/API/WORKTREE_REMOVE.md +33 -0
  32. data/docs/PROMPT_BASED_HOOKS.md +386 -0
  33. data/docs/external/claude-hooks-reference.md +2406 -854
  34. data/docs/mitts/ideabox.md +12 -0
  35. data/docs/mitts/prd.md +417 -0
  36. data/docs/mitts/setup.md +303 -0
  37. data/docs/mitts/task.md +44 -0
  38. data/lib/claude_hooks/base.rb +25 -0
  39. data/lib/claude_hooks/config_change.rb +28 -0
  40. data/lib/claude_hooks/cwd_changed.rb +28 -0
  41. data/lib/claude_hooks/elicitation.rb +61 -0
  42. data/lib/claude_hooks/elicitation_result.rb +57 -0
  43. data/lib/claude_hooks/file_changed.rb +41 -0
  44. data/lib/claude_hooks/instructions_loaded.rb +23 -0
  45. data/lib/claude_hooks/message_display.rb +58 -0
  46. data/lib/claude_hooks/output/base.rb +46 -1
  47. data/lib/claude_hooks/output/config_change.rb +47 -0
  48. data/lib/claude_hooks/output/cwd_changed.rb +42 -0
  49. data/lib/claude_hooks/output/elicitation.rb +67 -0
  50. data/lib/claude_hooks/output/elicitation_result.rb +63 -0
  51. data/lib/claude_hooks/output/file_changed.rb +42 -0
  52. data/lib/claude_hooks/output/instructions_loaded.rb +19 -0
  53. data/lib/claude_hooks/output/message_display.rb +49 -0
  54. data/lib/claude_hooks/output/permission_denied.rb +42 -0
  55. data/lib/claude_hooks/output/permission_request.rb +50 -25
  56. data/lib/claude_hooks/output/post_compact.rb +18 -0
  57. data/lib/claude_hooks/output/post_tool_batch.rb +51 -0
  58. data/lib/claude_hooks/output/post_tool_use.rb +12 -0
  59. data/lib/claude_hooks/output/post_tool_use_failure.rb +42 -0
  60. data/lib/claude_hooks/output/pre_compact.rb +34 -3
  61. data/lib/claude_hooks/output/pre_tool_use.rb +8 -2
  62. data/lib/claude_hooks/output/session_start.rb +30 -8
  63. data/lib/claude_hooks/output/setup.rb +42 -0
  64. data/lib/claude_hooks/output/stop.rb +15 -0
  65. data/lib/claude_hooks/output/stop_failure.rb +19 -0
  66. data/lib/claude_hooks/output/subagent_start.rb +42 -0
  67. data/lib/claude_hooks/output/task_completed.rb +18 -0
  68. data/lib/claude_hooks/output/task_created.rb +18 -0
  69. data/lib/claude_hooks/output/teammate_idle.rb +18 -0
  70. data/lib/claude_hooks/output/user_prompt_expansion.rb +51 -0
  71. data/lib/claude_hooks/output/worktree_create.rb +60 -0
  72. data/lib/claude_hooks/output/worktree_remove.rb +19 -0
  73. data/lib/claude_hooks/permission_denied.rb +41 -0
  74. data/lib/claude_hooks/permission_request.rb +16 -9
  75. data/lib/claude_hooks/post_compact.rb +23 -0
  76. data/lib/claude_hooks/post_tool_batch.rb +59 -0
  77. data/lib/claude_hooks/post_tool_use.rb +10 -0
  78. data/lib/claude_hooks/post_tool_use_failure.rb +45 -0
  79. data/lib/claude_hooks/pre_compact.rb +7 -0
  80. data/lib/claude_hooks/pre_tool_use.rb +20 -2
  81. data/lib/claude_hooks/session_start.rb +28 -0
  82. data/lib/claude_hooks/setup.rb +25 -0
  83. data/lib/claude_hooks/stop.rb +17 -0
  84. data/lib/claude_hooks/stop_failure.rb +27 -0
  85. data/lib/claude_hooks/subagent_start.rb +23 -0
  86. data/lib/claude_hooks/subagent_stop.rb +5 -1
  87. data/lib/claude_hooks/task_completed.rb +35 -0
  88. data/lib/claude_hooks/task_created.rb +35 -0
  89. data/lib/claude_hooks/teammate_idle.rb +23 -0
  90. data/lib/claude_hooks/user_prompt_expansion.rb +45 -0
  91. data/lib/claude_hooks/version.rb +1 -1
  92. data/lib/claude_hooks/worktree_create.rb +27 -0
  93. data/lib/claude_hooks/worktree_remove.rb +19 -0
  94. data/lib/claude_hooks.rb +40 -0
  95. metadata +71 -2
@@ -40,6 +40,10 @@ module ClaudeHooks
40
40
  @data['hookSpecificOutput'] || {}
41
41
  end
42
42
 
43
+ def terminal_sequence
44
+ @data['terminalSequence']
45
+ end
46
+
43
47
  # === JSON SERIALIZATION ===
44
48
 
45
49
  # Convert to JSON string (same as existing stringify_output)
@@ -100,11 +104,12 @@ module ClaudeHooks
100
104
  # Apply base merge logic
101
105
  compacted_outputs.each do |output|
102
106
  output_data = output.respond_to?(:data) ? output.data : output
103
-
107
+
104
108
  merged_data['continue'] = false if output_data['continue'] == false
105
109
  merged_data['suppressOutput'] = true if output_data['suppressOutput'] == true
106
110
  merged_data['stopReason'] = [merged_data['stopReason'], output_data['stopReason']].compact.reject(&:empty?).join('; ')
107
111
  merged_data['systemMessage'] = [merged_data['systemMessage'], output_data['systemMessage']].compact.reject(&:empty?).join('; ')
112
+ merged_data['terminalSequence'] = output_data['terminalSequence'] unless output_data['terminalSequence'].nil?
108
113
  end
109
114
 
110
115
  self.new(merged_data)
@@ -129,12 +134,52 @@ module ClaudeHooks
129
134
  SubagentStop.new(data)
130
135
  when 'Notification'
131
136
  Notification.new(data)
137
+ when 'MessageDisplay'
138
+ MessageDisplay.new(data)
132
139
  when 'SessionStart'
133
140
  SessionStart.new(data)
134
141
  when 'SessionEnd'
135
142
  SessionEnd.new(data)
136
143
  when 'PreCompact'
137
144
  PreCompact.new(data)
145
+ when 'Setup'
146
+ Setup.new(data)
147
+ when 'SubagentStart'
148
+ SubagentStart.new(data)
149
+ when 'UserPromptExpansion'
150
+ UserPromptExpansion.new(data)
151
+ when 'PostToolBatch'
152
+ PostToolBatch.new(data)
153
+ when 'ConfigChange'
154
+ ConfigChange.new(data)
155
+ when 'TaskCreated'
156
+ TaskCreated.new(data)
157
+ when 'TaskCompleted'
158
+ TaskCompleted.new(data)
159
+ when 'TeammateIdle'
160
+ TeammateIdle.new(data)
161
+ when 'PostToolUseFailure'
162
+ PostToolUseFailure.new(data)
163
+ when 'StopFailure'
164
+ StopFailure.new(data)
165
+ when 'PostCompact'
166
+ PostCompact.new(data)
167
+ when 'CwdChanged'
168
+ CwdChanged.new(data)
169
+ when 'FileChanged'
170
+ FileChanged.new(data)
171
+ when 'InstructionsLoaded'
172
+ InstructionsLoaded.new(data)
173
+ when 'WorktreeRemove'
174
+ WorktreeRemove.new(data)
175
+ when 'PermissionDenied'
176
+ PermissionDenied.new(data)
177
+ when 'Elicitation'
178
+ Elicitation.new(data)
179
+ when 'ElicitationResult'
180
+ ElicitationResult.new(data)
181
+ when 'WorktreeCreate'
182
+ WorktreeCreate.new(data)
138
183
  else
139
184
  raise ArgumentError, "Unknown hook type: #{hook_type}"
140
185
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class ConfigChange < 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 exit_code
21
+ default_exit_code
22
+ end
23
+
24
+ def self.merge(*outputs)
25
+ compacted_outputs = outputs.compact
26
+ return compacted_outputs.first if compacted_outputs.length == 1
27
+ return super(*outputs) if compacted_outputs.empty?
28
+
29
+ merged = super(*outputs)
30
+ merged_data = merged.data
31
+ reasons = []
32
+
33
+ compacted_outputs.each do |output|
34
+ output_data = output.respond_to?(:data) ? output.data : output
35
+ if output_data['decision'] == 'block'
36
+ merged_data['decision'] = 'block'
37
+ r = output_data['reason']
38
+ reasons << r if r && !r.empty?
39
+ end
40
+ end
41
+
42
+ merged_data['reason'] = reasons.join('; ') if reasons.any?
43
+ new(merged_data)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class CwdChanged < Base
8
+ def watch_paths
9
+ hook_specific_output['watchPaths'] || []
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
+ all_paths = []
24
+
25
+ compacted_outputs.each do |output|
26
+ output_data = output.respond_to?(:data) ? output.data : output
27
+ paths = output_data.dig('hookSpecificOutput', 'watchPaths')
28
+ all_paths.concat(paths) if paths&.any?
29
+ end
30
+
31
+ unless all_paths.empty?
32
+ merged_data['hookSpecificOutput'] = {
33
+ 'hookEventName' => 'CwdChanged',
34
+ 'watchPaths' => all_paths.uniq
35
+ }
36
+ end
37
+
38
+ new(merged_data)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class Elicitation < Base
8
+ def action
9
+ hook_specific_output['action']
10
+ end
11
+
12
+ def content
13
+ hook_specific_output['content'] || {}
14
+ end
15
+
16
+ def accepted?
17
+ action == 'accept'
18
+ end
19
+
20
+ def declined?
21
+ action == 'decline'
22
+ end
23
+
24
+ def cancelled?
25
+ action == 'cancel'
26
+ end
27
+
28
+ def exit_code
29
+ 0
30
+ end
31
+
32
+ def output_stream
33
+ :stdout
34
+ end
35
+
36
+ def self.merge(*outputs)
37
+ compacted_outputs = outputs.compact
38
+ return compacted_outputs.first if compacted_outputs.length == 1
39
+ return super(*outputs) if compacted_outputs.empty?
40
+
41
+ # Last non-nil action wins
42
+ merged = super(*outputs)
43
+ merged_data = merged.data
44
+ last_action = nil
45
+ last_content = nil
46
+
47
+ compacted_outputs.each do |output|
48
+ output_data = output.respond_to?(:data) ? output.data : output
49
+ a = output_data.dig('hookSpecificOutput', 'action')
50
+ next unless a
51
+ last_action = a
52
+ last_content = output_data.dig('hookSpecificOutput', 'content')
53
+ end
54
+
55
+ if last_action
56
+ merged_data['hookSpecificOutput'] = {
57
+ 'hookEventName' => 'Elicitation',
58
+ 'action' => last_action
59
+ }
60
+ merged_data['hookSpecificOutput']['content'] = last_content if last_content
61
+ end
62
+
63
+ new(merged_data)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class ElicitationResult < Base
8
+ def action
9
+ hook_specific_output['action']
10
+ end
11
+
12
+ def content
13
+ hook_specific_output['content'] || {}
14
+ end
15
+
16
+ def accepted?
17
+ action == 'accept'
18
+ end
19
+
20
+ def declined?
21
+ action == 'decline'
22
+ end
23
+
24
+ def cancelled?
25
+ action == 'cancel'
26
+ end
27
+
28
+ def exit_code
29
+ 0
30
+ end
31
+
32
+ def output_stream
33
+ :stdout
34
+ end
35
+
36
+ def self.merge(*outputs)
37
+ compacted_outputs = outputs.compact
38
+ return compacted_outputs.first if compacted_outputs.length == 1
39
+ return super(*outputs) if compacted_outputs.empty?
40
+
41
+ merged = super(*outputs)
42
+ merged_data = merged.data
43
+ last_action = nil
44
+ last_content = nil
45
+
46
+ compacted_outputs.each do |output|
47
+ output_data = output.respond_to?(:data) ? output.data : output
48
+ a = output_data.dig('hookSpecificOutput', 'action')
49
+ next unless a
50
+ last_action = a
51
+ last_content = output_data.dig('hookSpecificOutput', 'content')
52
+ end
53
+
54
+ if last_action
55
+ merged_data['hookSpecificOutput'] = { 'hookEventName' => 'ElicitationResult', 'action' => last_action }
56
+ merged_data['hookSpecificOutput']['content'] = last_content if last_content
57
+ end
58
+
59
+ new(merged_data)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class FileChanged < Base
8
+ def watch_paths
9
+ hook_specific_output['watchPaths'] || []
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
+ all_paths = []
24
+
25
+ compacted_outputs.each do |output|
26
+ output_data = output.respond_to?(:data) ? output.data : output
27
+ paths = output_data.dig('hookSpecificOutput', 'watchPaths')
28
+ all_paths.concat(paths) if paths&.any?
29
+ end
30
+
31
+ unless all_paths.empty?
32
+ merged_data['hookSpecificOutput'] = {
33
+ 'hookEventName' => 'FileChanged',
34
+ 'watchPaths' => all_paths.uniq
35
+ }
36
+ end
37
+
38
+ new(merged_data)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ # InstructionsLoaded is non-blocking — exit code is ignored by Claude Code.
8
+ class InstructionsLoaded < 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,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ # MessageDisplay is display-only — exit code and decision fields are ignored.
8
+ # Only hookSpecificOutput.displayContent has an effect (replaces on-screen text).
9
+ class MessageDisplay < Base
10
+ def display_content
11
+ hook_specific_output['displayContent']
12
+ end
13
+
14
+ def exit_code
15
+ 0
16
+ end
17
+
18
+ def output_stream
19
+ :stdout
20
+ end
21
+
22
+ # Last non-nil displayContent wins.
23
+ def self.merge(*outputs)
24
+ compacted_outputs = outputs.compact
25
+ return compacted_outputs.first if compacted_outputs.length == 1
26
+ return super(*outputs) if compacted_outputs.empty?
27
+
28
+ merged = super(*outputs)
29
+ merged_data = merged.data
30
+ last_content = nil
31
+
32
+ compacted_outputs.each do |output|
33
+ output_data = output.respond_to?(:data) ? output.data : output
34
+ content = output_data.dig('hookSpecificOutput', 'displayContent')
35
+ last_content = content unless content.nil?
36
+ end
37
+
38
+ unless last_content.nil?
39
+ merged_data['hookSpecificOutput'] = {
40
+ 'hookEventName' => 'MessageDisplay',
41
+ 'displayContent' => last_content
42
+ }
43
+ end
44
+
45
+ new(merged_data)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class PermissionDenied < Base
8
+ def retry?
9
+ hook_specific_output['retry'] == true
10
+ end
11
+
12
+ # Exit code is ignored by Claude Code for PermissionDenied.
13
+ def exit_code
14
+ 0
15
+ end
16
+
17
+ def output_stream
18
+ :stdout
19
+ end
20
+
21
+ def self.merge(*outputs)
22
+ compacted_outputs = outputs.compact
23
+ return compacted_outputs.first if compacted_outputs.length == 1
24
+ return super(*outputs) if compacted_outputs.empty?
25
+
26
+ merged = super(*outputs)
27
+ merged_data = merged.data
28
+
29
+ # If any hook says retry, retry
30
+ should_retry = compacted_outputs.any? do |output|
31
+ output_data = output.respond_to?(:data) ? output.data : output
32
+ output_data.dig('hookSpecificOutput', 'retry') == true
33
+ end
34
+
35
+ merged_data['hookSpecificOutput'] ||= { 'hookEventName' => 'PermissionDenied' }
36
+ merged_data['hookSpecificOutput']['retry'] = should_retry
37
+
38
+ new(merged_data)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -7,16 +7,33 @@ module ClaudeHooks
7
7
  class PermissionRequest < Base
8
8
  # === PERMISSION DECISION ACCESSORS ===
9
9
 
10
+ def behavior
11
+ hook_specific_output.dig('decision', 'behavior')
12
+ end
13
+
14
+ # Read nested decision.behavior with flat legacy fallback
10
15
  def permission_decision
11
- hook_specific_output['permissionDecision']
16
+ behavior || hook_specific_output['permissionDecision']
12
17
  end
13
18
 
19
+ # Read decision.message with legacy permissionDecisionReason fallback
14
20
  def permission_reason
15
- hook_specific_output['permissionDecisionReason'] || ''
21
+ hook_specific_output.dig('decision', 'message') ||
22
+ hook_specific_output['permissionDecisionReason'] || ''
16
23
  end
17
24
 
25
+ # Read decision.updatedInput with flat legacy fallback
18
26
  def updated_input
19
- hook_specific_output['updatedInput']
27
+ hook_specific_output.dig('decision', 'updatedInput') ||
28
+ hook_specific_output['updatedInput']
29
+ end
30
+
31
+ def updated_permissions
32
+ hook_specific_output.dig('decision', 'updatedPermissions')
33
+ end
34
+
35
+ def interrupt?
36
+ hook_specific_output.dig('decision', 'interrupt') == true
20
37
  end
21
38
 
22
39
  # === SEMANTIC HELPERS ===
@@ -34,16 +51,13 @@ module ClaudeHooks
34
51
  end
35
52
 
36
53
  # === EXIT CODE LOGIC ===
37
- #
38
- # PermissionRequest hooks use the advanced JSON API with exit code 0.
39
- # Following the same pattern as PreToolUse (permission-based hooks).
54
+
40
55
  def exit_code
41
56
  0
42
57
  end
43
58
 
44
59
  # === OUTPUT STREAM LOGIC ===
45
- #
46
- # PermissionRequest hooks always output to stdout when using the JSON API.
60
+
47
61
  def output_stream
48
62
  :stdout
49
63
  end
@@ -58,35 +72,46 @@ module ClaudeHooks
58
72
  merged = super(*outputs)
59
73
  merged_data = merged.data
60
74
 
61
- # PermissionRequest specific merge: deny > allow (most restrictive wins)
62
- permission_decision = 'allow'
63
- permission_reasons = []
75
+ # deny > allow (most restrictive wins)
76
+ behavior = 'allow'
77
+ messages = []
64
78
  updated_inputs = []
79
+ interrupt = false
80
+ updated_permissions = nil
65
81
 
66
82
  compacted_outputs.each do |output|
67
83
  output_data = output.respond_to?(:data) ? output.data : output
68
84
 
69
- next unless output_data.dig('hookSpecificOutput', 'permissionDecision')
85
+ # Support both nested and legacy flat shapes
86
+ current_behavior = output_data.dig('hookSpecificOutput', 'decision', 'behavior') ||
87
+ output_data.dig('hookSpecificOutput', 'permissionDecision')
88
+ next unless current_behavior
70
89
 
71
- current_decision = output_data['hookSpecificOutput']['permissionDecision']
72
- permission_decision = 'deny' if current_decision == 'deny'
90
+ behavior = 'deny' if current_behavior == 'deny'
73
91
 
74
- reason = output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
75
- permission_reasons << reason if reason && !reason.empty?
92
+ msg = output_data.dig('hookSpecificOutput', 'decision', 'message') ||
93
+ output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
94
+ messages << msg if msg && !msg.empty?
76
95
 
77
- updated = output_data.dig('hookSpecificOutput', 'updatedInput')
96
+ updated = output_data.dig('hookSpecificOutput', 'decision', 'updatedInput') ||
97
+ output_data.dig('hookSpecificOutput', 'updatedInput')
78
98
  updated_inputs << updated if updated
99
+
100
+ # Propagate interrupt (OR logic — if any output has it, merged should too)
101
+ interrupt = true if output_data.dig('hookSpecificOutput', 'decision', 'interrupt') == true
102
+
103
+ # Propagate updatedPermissions (last writer wins)
104
+ perms = output_data.dig('hookSpecificOutput', 'decision', 'updatedPermissions')
105
+ updated_permissions = perms unless perms.nil?
79
106
  end
80
107
 
81
108
  merged_data['hookSpecificOutput'] ||= { 'hookEventName' => 'PermissionRequest' }
82
- merged_data['hookSpecificOutput']['permissionDecision'] = permission_decision
83
- merged_data['hookSpecificOutput']['permissionDecisionReason'] = if permission_reasons.any?
84
- permission_reasons.join('; ')
85
- else
86
- ''
87
- end
88
- # Last updated input wins
89
- merged_data['hookSpecificOutput']['updatedInput'] = updated_inputs.last if updated_inputs.any?
109
+ decision = { 'behavior' => behavior }
110
+ decision['message'] = messages.join('; ') if messages.any?
111
+ decision['updatedInput'] = updated_inputs.last if updated_inputs.any?
112
+ decision['interrupt'] = interrupt
113
+ decision['updatedPermissions'] = updated_permissions unless updated_permissions.nil?
114
+ merged_data['hookSpecificOutput']['decision'] = decision
90
115
 
91
116
  new(merged_data)
92
117
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class PostCompact < 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 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.