claude_hooks 1.1.0 → 1.2.1

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 (101) 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/AGENTS.md +46 -0
  5. data/CHANGELOG.md +64 -0
  6. data/README.md +303 -239
  7. data/docs/1.0.0_MIGRATION_GUIDE.md +6 -7
  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/example_dotclaude/hooks/entrypoints/session_end.rb +5 -22
  39. data/example_dotclaude/hooks/entrypoints/user_prompt_submit.rb +4 -26
  40. data/example_dotclaude/hooks/{handlers/pre_tool_use/github_guard.rb → github_guard.rb} +7 -13
  41. data/example_dotclaude/settings.json +1 -1
  42. data/lib/claude_hooks/base.rb +25 -0
  43. data/lib/claude_hooks/cli.rb +84 -149
  44. data/lib/claude_hooks/config_change.rb +28 -0
  45. data/lib/claude_hooks/cwd_changed.rb +28 -0
  46. data/lib/claude_hooks/elicitation.rb +61 -0
  47. data/lib/claude_hooks/elicitation_result.rb +57 -0
  48. data/lib/claude_hooks/file_changed.rb +41 -0
  49. data/lib/claude_hooks/instructions_loaded.rb +23 -0
  50. data/lib/claude_hooks/message_display.rb +58 -0
  51. data/lib/claude_hooks/output/base.rb +46 -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 +50 -25
  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 +8 -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 +16 -9
  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 +10 -0
  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 +20 -2
  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 +40 -0
  100. metadata +71 -4
  101. data/example_dotclaude/hooks/entrypoints/pre_tool_use.rb +0 -25
@@ -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.
@@ -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