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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ # MessageDisplay runs while assistant message text is displayed on screen.
7
+ # Display-only: exit code and decision fields are ignored. The only effect is
8
+ # replacing the on-screen text via hookSpecificOutput.displayContent — the
9
+ # transcript and what Claude sees keep the original text.
10
+ class MessageDisplay < Base
11
+ def self.hook_type
12
+ 'MessageDisplay'
13
+ end
14
+
15
+ def self.input_fields
16
+ %w[turn_id message_id index final delta]
17
+ end
18
+
19
+ # === INPUT DATA ACCESS ===
20
+
21
+ def turn_id
22
+ @input_data['turn_id'] || @input_data['turnId']
23
+ end
24
+
25
+ def message_id
26
+ @input_data['message_id'] || @input_data['messageId']
27
+ end
28
+
29
+ def index
30
+ @input_data['index']
31
+ end
32
+
33
+ def final
34
+ @input_data.key?('final') ? @input_data['final'] : @input_data['isFinal']
35
+ end
36
+ alias_method :final?, :final
37
+
38
+ # The streamed assistant text delta for this event.
39
+ def delta
40
+ @input_data['delta']
41
+ end
42
+
43
+ # Some payloads expose the full text so far; kept as a convenience reader.
44
+ def message_text
45
+ @input_data['message_text'] || @input_data['messageText']
46
+ end
47
+
48
+ # === OUTPUT DATA HELPERS ===
49
+
50
+ # Replace the text shown on screen (display-only).
51
+ def display_content!(content)
52
+ @output_data['hookSpecificOutput'] = {
53
+ 'hookEventName' => hook_event_name,
54
+ 'displayContent' => content
55
+ }
56
+ end
57
+ end
58
+ end
@@ -9,7 +9,7 @@ module ClaudeHooks
9
9
  end
10
10
 
11
11
  def self.input_fields
12
- %w[message]
12
+ %w[message notification_type]
13
13
  end
14
14
 
15
15
  # === INPUT DATA ACCESS ===
@@ -18,5 +18,9 @@ module ClaudeHooks
18
18
  @input_data['message']
19
19
  end
20
20
  alias_method :notification_message, :message
21
+
22
+ def notification_type
23
+ @input_data['notification_type'] || @input_data['notificationType']
24
+ end
21
25
  end
22
26
  end
@@ -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)
@@ -119,6 +124,8 @@ module ClaudeHooks
119
124
  UserPromptSubmit.new(data)
120
125
  when 'PreToolUse'
121
126
  PreToolUse.new(data)
127
+ when 'PermissionRequest'
128
+ PermissionRequest.new(data)
122
129
  when 'PostToolUse'
123
130
  PostToolUse.new(data)
124
131
  when 'Stop'
@@ -127,12 +134,52 @@ module ClaudeHooks
127
134
  SubagentStop.new(data)
128
135
  when 'Notification'
129
136
  Notification.new(data)
137
+ when 'MessageDisplay'
138
+ MessageDisplay.new(data)
130
139
  when 'SessionStart'
131
140
  SessionStart.new(data)
132
141
  when 'SessionEnd'
133
142
  SessionEnd.new(data)
134
143
  when 'PreCompact'
135
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)
136
183
  else
137
184
  raise ArgumentError, "Unknown hook type: #{hook_type}"
138
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
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class PermissionRequest < Base
8
+ # === PERMISSION DECISION ACCESSORS ===
9
+
10
+ def behavior
11
+ hook_specific_output.dig('decision', 'behavior')
12
+ end
13
+
14
+ # Read nested decision.behavior with flat legacy fallback
15
+ def permission_decision
16
+ behavior || hook_specific_output['permissionDecision']
17
+ end
18
+
19
+ # Read decision.message with legacy permissionDecisionReason fallback
20
+ def permission_reason
21
+ hook_specific_output.dig('decision', 'message') ||
22
+ hook_specific_output['permissionDecisionReason'] || ''
23
+ end
24
+
25
+ # Read decision.updatedInput with flat legacy fallback
26
+ def updated_input
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
37
+ end
38
+
39
+ # === SEMANTIC HELPERS ===
40
+
41
+ def allowed?
42
+ permission_decision == 'allow'
43
+ end
44
+
45
+ def denied?
46
+ permission_decision == 'deny'
47
+ end
48
+
49
+ def input_updated?
50
+ !updated_input.nil?
51
+ end
52
+
53
+ # === EXIT CODE LOGIC ===
54
+
55
+ def exit_code
56
+ 0
57
+ end
58
+
59
+ # === OUTPUT STREAM LOGIC ===
60
+
61
+ def output_stream
62
+ :stdout
63
+ end
64
+
65
+ # === MERGE HELPER ===
66
+
67
+ def self.merge(*outputs)
68
+ compacted_outputs = outputs.compact
69
+ return compacted_outputs.first if compacted_outputs.length == 1
70
+ return super(*outputs) if compacted_outputs.empty?
71
+
72
+ merged = super(*outputs)
73
+ merged_data = merged.data
74
+
75
+ # deny > allow (most restrictive wins)
76
+ behavior = 'allow'
77
+ messages = []
78
+ updated_inputs = []
79
+ interrupt = false
80
+ updated_permissions = nil
81
+
82
+ compacted_outputs.each do |output|
83
+ output_data = output.respond_to?(:data) ? output.data : output
84
+
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
89
+
90
+ behavior = 'deny' if current_behavior == 'deny'
91
+
92
+ msg = output_data.dig('hookSpecificOutput', 'decision', 'message') ||
93
+ output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
94
+ messages << msg if msg && !msg.empty?
95
+
96
+ updated = output_data.dig('hookSpecificOutput', 'decision', 'updatedInput') ||
97
+ output_data.dig('hookSpecificOutput', 'updatedInput')
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?
106
+ end
107
+
108
+ merged_data['hookSpecificOutput'] ||= { 'hookEventName' => 'PermissionRequest' }
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
115
+
116
+ new(merged_data)
117
+ end
118
+ end
119
+ end
120
+ 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