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
@@ -38,6 +38,10 @@ module ClaudeHooks
38
38
  !updated_input.nil?
39
39
  end
40
40
 
41
+ def deferred?
42
+ permission_decision == 'defer'
43
+ end
44
+
41
45
  # === EXIT CODE LOGIC ===
42
46
  #
43
47
  # PreToolUse hooks use the advanced JSON API with exit code 0.
@@ -66,7 +70,7 @@ module ClaudeHooks
66
70
  merged = super(*outputs)
67
71
  merged_data = merged.data
68
72
 
69
- # PreToolUse specific merge: deny > ask > allow (most restrictive wins)
73
+ # PreToolUse specific merge: deny > defer > ask > allow (most restrictive wins)
70
74
  permission_decision = 'allow'
71
75
  permission_reasons = []
72
76
 
@@ -79,8 +83,10 @@ module ClaudeHooks
79
83
  case current_decision
80
84
  when 'deny'
81
85
  permission_decision = 'deny'
86
+ when 'defer'
87
+ permission_decision = 'defer' unless permission_decision == 'deny'
82
88
  when 'ask'
83
- permission_decision = 'ask' unless permission_decision == 'deny'
89
+ permission_decision = 'ask' unless %w[deny defer].include?(permission_decision)
84
90
  end
85
91
 
86
92
  reason = output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
@@ -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
@@ -26,30 +26,37 @@ module ClaudeHooks
26
26
  @input_data['tool_use_id'] || @input_data['toolUseId']
27
27
  end
28
28
 
29
+ def permission_suggestions
30
+ @input_data['permissionSuggestions'] || @input_data['permission_suggestions'] || []
31
+ end
32
+
29
33
  # === OUTPUT DATA HELPERS ===
30
34
 
31
35
  def allow_permission!(reason = '')
32
36
  @output_data['hookSpecificOutput'] = {
33
37
  'hookEventName' => hook_event_name,
34
- 'permissionDecision' => 'allow',
35
- 'permissionDecisionReason' => reason
38
+ 'decision' => { 'behavior' => 'allow' }
36
39
  }
40
+ @output_data['hookSpecificOutput']['decision']['message'] = reason unless reason.empty?
37
41
  end
38
42
 
39
- def deny_permission!(reason = '')
43
+ def deny_permission!(reason = '', interrupt: nil)
44
+ decision = { 'behavior' => 'deny' }
45
+ decision['message'] = reason unless reason.empty?
46
+ decision['interrupt'] = interrupt unless interrupt.nil?
40
47
  @output_data['hookSpecificOutput'] = {
41
48
  'hookEventName' => hook_event_name,
42
- 'permissionDecision' => 'deny',
43
- 'permissionDecisionReason' => reason
49
+ 'decision' => decision
44
50
  }
45
51
  end
46
52
 
47
- def update_input_and_allow!(updated_input, reason = '')
53
+ def update_input_and_allow!(updated_input, reason = '', updated_permissions: nil)
54
+ decision = { 'behavior' => 'allow', 'updatedInput' => updated_input }
55
+ decision['message'] = reason unless reason.empty?
56
+ decision['updatedPermissions'] = updated_permissions unless updated_permissions.nil?
48
57
  @output_data['hookSpecificOutput'] = {
49
58
  'hookEventName' => hook_event_name,
50
- 'permissionDecision' => 'allow',
51
- 'permissionDecisionReason' => reason,
52
- 'updatedInput' => updated_input
59
+ 'decision' => decision
53
60
  }
54
61
  end
55
62
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ class PostCompact < Base
7
+ def self.hook_type
8
+ 'PostCompact'
9
+ end
10
+
11
+ def self.input_fields
12
+ %w[trigger compact_summary]
13
+ end
14
+
15
+ def trigger
16
+ @input_data['trigger']
17
+ end
18
+
19
+ def compact_summary
20
+ @input_data['compact_summary'] || @input_data['compactSummary']
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ class PostToolBatch < Base
7
+ def self.hook_type
8
+ 'PostToolBatch'
9
+ end
10
+
11
+ def self.input_fields
12
+ %w[tool_calls]
13
+ end
14
+
15
+ def tool_calls
16
+ @input_data['tool_calls'] || @input_data['toolCalls'] || []
17
+ end
18
+
19
+ # Convenience partitions over tool_calls. Entries carry a `tool_response`
20
+ # rather than an explicit success flag, so success is derived from the
21
+ # presence of a response that isn't flagged as an error. When your payload
22
+ # shape differs, use the raw `tool_calls` array instead.
23
+ def succeeded_calls
24
+ tool_calls.reject { |call| failed_call?(call) }
25
+ end
26
+
27
+ def failed_calls
28
+ tool_calls.select { |call| failed_call?(call) }
29
+ end
30
+
31
+ def block!(reason = '')
32
+ @output_data['decision'] = 'block'
33
+ @output_data['reason'] = reason
34
+ end
35
+
36
+ def add_additional_context!(context)
37
+ @output_data['hookSpecificOutput'] ||= { 'hookEventName' => hook_event_name }
38
+ @output_data['hookSpecificOutput']['additionalContext'] = context
39
+ end
40
+
41
+ private
42
+
43
+ # A call is considered failed when its response is missing or carries an
44
+ # error marker (`is_error`/`isError` true, or an `error` key).
45
+ def failed_call?(call)
46
+ return true unless call.is_a?(Hash)
47
+
48
+ response = call['tool_response'] || call['toolResponse']
49
+ return true if response.nil?
50
+
51
+ if response.is_a?(Hash)
52
+ return true if response['is_error'] == true || response['isError'] == true
53
+ return true if response.key?('error') && !response['error'].nil?
54
+ end
55
+
56
+ false
57
+ end
58
+ end
59
+ end
@@ -49,5 +49,15 @@ module ClaudeHooks
49
49
  }
50
50
  end
51
51
  alias_method :add_context!, :add_additional_context!
52
+
53
+ def update_tool_output!(value)
54
+ @output_data['hookSpecificOutput'] ||= { 'hookEventName' => hook_event_name }
55
+ @output_data['hookSpecificOutput']['updatedToolOutput'] = value
56
+ end
57
+
58
+ def update_mcp_tool_output!(value)
59
+ @output_data['hookSpecificOutput'] ||= { 'hookEventName' => hook_event_name }
60
+ @output_data['hookSpecificOutput']['updatedMCPToolOutput'] = value
61
+ end
52
62
  end
53
63
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ class PostToolUseFailure < Base
7
+ def self.hook_type
8
+ 'PostToolUseFailure'
9
+ end
10
+
11
+ def self.input_fields
12
+ %w[tool_name tool_input tool_use_id error]
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 error
28
+ @input_data['error']
29
+ end
30
+
31
+ def is_interrupt
32
+ @input_data.key?('is_interrupt') ? @input_data['is_interrupt'] : @input_data['isInterrupt']
33
+ end
34
+ alias_method :interrupt?, :is_interrupt
35
+
36
+ def duration_ms
37
+ @input_data['duration_ms'] || @input_data['durationMs']
38
+ end
39
+
40
+ def add_additional_context!(context)
41
+ @output_data['hookSpecificOutput'] ||= { 'hookEventName' => hook_event_name }
42
+ @output_data['hookSpecificOutput']['additionalContext'] = context
43
+ end
44
+ end
45
+ end
@@ -27,6 +27,13 @@ module ClaudeHooks
27
27
  end
28
28
  end
29
29
 
30
+ # === OUTPUT DATA HELPERS ===
31
+
32
+ def block!(reason = '')
33
+ @output_data['decision'] = 'block'
34
+ @output_data['reason'] = reason
35
+ end
36
+
30
37
  # === UTILITY HELPERS ===
31
38
 
32
39
  def backup_transcript!(backup_file_path)