claude_hooks 1.3.0 → 1.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +19 -15
- data/docs/API/COMMON.md +4 -3
- data/docs/API/NOTIFICATION.md +5 -4
- data/docs/API/PERMISSION_DENIED.md +4 -0
- data/docs/API/PERMISSION_REQUEST.md +3 -3
- data/docs/API/POST_MODEL_SWITCH.md +53 -0
- data/docs/API/POST_TOOL_USE.md +7 -0
- data/docs/API/POST_TOOL_USE_FAILURE.md +1 -0
- data/docs/API/PRE_MODEL_SWITCH.md +67 -0
- data/docs/API/PRE_TOOL_USE.md +1 -0
- data/docs/API/SESSION_START.md +7 -1
- data/docs/API/SETUP.md +3 -3
- data/docs/API/STOP.md +6 -0
- data/docs/API/SUBAGENT_STOP.md +10 -0
- data/docs/API/TASK_CREATED.md +9 -7
- data/docs/API/USER_PROMPT_SUBMIT.md +14 -0
- data/docs/API/WORKTREE_REMOVE.md +9 -4
- data/docs/external/claude-hooks-reference.md +973 -294
- data/docs/mitts/prd.md +8 -0
- data/docs/mitts/setup.md +5 -3
- data/lib/claude_hooks/base.rb +12 -0
- data/lib/claude_hooks/output/base.rb +4 -0
- data/lib/claude_hooks/output/notification.rb +10 -1
- data/lib/claude_hooks/output/post_model_switch.rb +50 -0
- data/lib/claude_hooks/output/post_tool_batch.rb +10 -0
- data/lib/claude_hooks/output/post_tool_use.rb +23 -5
- data/lib/claude_hooks/output/pre_model_switch.rb +121 -0
- data/lib/claude_hooks/output/pre_tool_use.rb +8 -0
- data/lib/claude_hooks/output/task_created.rb +34 -1
- data/lib/claude_hooks/output/user_prompt_expansion.rb +10 -0
- data/lib/claude_hooks/output/user_prompt_submit.rb +19 -6
- data/lib/claude_hooks/output/worktree_remove.rb +4 -2
- data/lib/claude_hooks/permission_denied.rb +7 -0
- data/lib/claude_hooks/permission_request.rb +10 -1
- data/lib/claude_hooks/post_model_switch.rb +70 -0
- data/lib/claude_hooks/post_tool_use.rb +14 -0
- data/lib/claude_hooks/post_tool_use_failure.rb +7 -0
- data/lib/claude_hooks/pre_model_switch.rb +90 -0
- data/lib/claude_hooks/pre_tool_use.rb +7 -0
- data/lib/claude_hooks/session_start.rb +18 -0
- data/lib/claude_hooks/setup.rb +3 -0
- data/lib/claude_hooks/task_created.rb +17 -0
- data/lib/claude_hooks/user_prompt_submit.rb +26 -5
- data/lib/claude_hooks/version.rb +1 -1
- data/lib/claude_hooks.rb +4 -0
- metadata +8 -2
data/docs/mitts/prd.md
CHANGED
|
@@ -102,6 +102,14 @@ The `pr-review` action loads `AGENTS.md` (via its own `load_project_skills` call
|
|
|
102
102
|
but does not use `agent_task.py`, so it doesn't have cross-run memory or the
|
|
103
103
|
guardrail hook.
|
|
104
104
|
|
|
105
|
+
The workflow runs the upstream review script (`plugins/pr-review/scripts/agent_script.py`,
|
|
106
|
+
pinned to an `OpenHands/extensions` commit) through `.github/mitts/pr_review_entry.py`.
|
|
107
|
+
The upstream script can't pass extra request headers, and the OpenCode gateway rejects
|
|
108
|
+
requests without `x-opencode-session`. So the wrapper adds the same headers as
|
|
109
|
+
`agent_task.py`: `User-Agent: mitts-agent/1.0`, plus `x-opencode-session` set to
|
|
110
|
+
`OPENHANDS_CONVERSATION_KEY` (`pr-review-<number>`). The wrapper is checked out from the
|
|
111
|
+
default branch, never from the PR under review.
|
|
112
|
+
|
|
105
113
|
### Issue and PR bots — `agent_task.py`
|
|
106
114
|
|
|
107
115
|
There is no turnkey OpenHands Action for "issue → PR" or "PR follow-up", so these
|
data/docs/mitts/setup.md
CHANGED
|
@@ -267,9 +267,11 @@ reference:
|
|
|
267
267
|
- `OPENHANDS_CONVERSATION_KEY` — per-thread key for the artifact (e.g. `pr-42`,
|
|
268
268
|
`issue-7`).
|
|
269
269
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
you
|
|
270
|
+
`mitts-pr-review.yml` sets its own `OPENHANDS_CONVERSATION_KEY` (`pr-review-<number>`),
|
|
271
|
+
which `.github/mitts/pr_review_entry.py` sends as the `x-opencode-session` header. It
|
|
272
|
+
pins the `OpenHands/extensions` checkout to a commit SHA. When you bump that SHA, check
|
|
273
|
+
that the upstream `agent_script.py` still builds its LLM with `openhands.sdk.LLM(...)`,
|
|
274
|
+
because the wrapper relies on it.
|
|
273
275
|
|
|
274
276
|
---
|
|
275
277
|
|
data/lib/claude_hooks/base.rb
CHANGED
|
@@ -92,6 +92,14 @@ module ClaudeHooks
|
|
|
92
92
|
@input_data.dig('effort', 'level')
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
+
# Path to the session's scratchpad directory. Absent when the session has no
|
|
96
|
+
# scratchpad or the temp directory is unavailable. Requires Claude Code v2.1.257+.
|
|
97
|
+
# Intentionally not in COMMON_INPUT_FIELDS: it is version-gated and often absent,
|
|
98
|
+
# so it must not trigger the missing-field warning in validate_input!.
|
|
99
|
+
def scratchpad_dir
|
|
100
|
+
@input_data['scratchpad_dir'] || @input_data['scratchpadDir']
|
|
101
|
+
end
|
|
102
|
+
|
|
95
103
|
def read_transcript
|
|
96
104
|
unless transcript_path && File.exist?(transcript_path)
|
|
97
105
|
log "Transcript file not found at #{transcript_path}", level: :warn
|
|
@@ -120,10 +128,14 @@ module ClaudeHooks
|
|
|
120
128
|
end
|
|
121
129
|
|
|
122
130
|
# Hide stdout from transcript mode (default: false)
|
|
131
|
+
#
|
|
132
|
+
# DEPRECATED: Claude Code accepts the `suppressOutput` field but no longer acts
|
|
133
|
+
# on it (it has no effect). Kept for backward compatibility; safe to call, but a no-op.
|
|
123
134
|
def suppress_output!
|
|
124
135
|
@output_data['suppressOutput'] = true
|
|
125
136
|
end
|
|
126
137
|
|
|
138
|
+
# DEPRECATED: see suppress_output! — `suppressOutput` is now a no-op in Claude Code.
|
|
127
139
|
def show_output!
|
|
128
140
|
@output_data['suppressOutput'] = false
|
|
129
141
|
end
|
|
@@ -182,6 +182,10 @@ module ClaudeHooks
|
|
|
182
182
|
WorktreeCreate.new(data)
|
|
183
183
|
when 'DirectoryAdded'
|
|
184
184
|
DirectoryAdded.new(data)
|
|
185
|
+
when 'PreModelSwitch'
|
|
186
|
+
PreModelSwitch.new(data)
|
|
187
|
+
when 'PostModelSwitch'
|
|
188
|
+
PostModelSwitch.new(data)
|
|
185
189
|
else
|
|
186
190
|
raise ArgumentError, "Unknown hook type: #{hook_type}"
|
|
187
191
|
end
|
|
@@ -6,9 +6,18 @@ module ClaudeHooks
|
|
|
6
6
|
module Output
|
|
7
7
|
class Notification < Base
|
|
8
8
|
# === EXIT CODE LOGIC ===
|
|
9
|
+
#
|
|
10
|
+
# Claude Code ignores Notification's exit code and stderr, and discards
|
|
11
|
+
# `systemMessage`/`continue`, while still emitting `terminalSequence`.
|
|
12
|
+
# So always exit 0 and write JSON to stdout, keeping the desktop-notification
|
|
13
|
+
# (`terminal_sequence!`) pattern working even when a handler set continue:false.
|
|
9
14
|
|
|
10
15
|
def exit_code
|
|
11
|
-
|
|
16
|
+
0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def output_stream
|
|
20
|
+
:stdout
|
|
12
21
|
end
|
|
13
22
|
|
|
14
23
|
# === MERGE HELPER ===
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
module Output
|
|
7
|
+
# PostModelSwitch is context-only — it can't block, because the model has
|
|
8
|
+
# already changed. Its stdout is shown to Claude as additional context.
|
|
9
|
+
class PostModelSwitch < Base
|
|
10
|
+
# === CONTEXT ACCESSORS ===
|
|
11
|
+
|
|
12
|
+
def additional_context
|
|
13
|
+
hook_specific_output['additionalContext'] || ''
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# === EXIT CODE LOGIC ===
|
|
17
|
+
|
|
18
|
+
def exit_code
|
|
19
|
+
default_exit_code
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# === MERGE HELPER ===
|
|
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
|
+
contexts = []
|
|
32
|
+
|
|
33
|
+
compacted_outputs.each do |output|
|
|
34
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
35
|
+
context = output_data.dig('hookSpecificOutput', 'additionalContext')
|
|
36
|
+
contexts << context if context && !context.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
unless contexts.empty?
|
|
40
|
+
merged_data['hookSpecificOutput'] = {
|
|
41
|
+
'hookEventName' => 'PostModelSwitch',
|
|
42
|
+
'additionalContext' => contexts.join("\n\n")
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
new(merged_data)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -33,6 +33,7 @@ module ClaudeHooks
|
|
|
33
33
|
merged = super(*outputs)
|
|
34
34
|
merged_data = merged.data
|
|
35
35
|
reasons = []
|
|
36
|
+
contexts = []
|
|
36
37
|
|
|
37
38
|
compacted_outputs.each do |output|
|
|
38
39
|
output_data = output.respond_to?(:data) ? output.data : output
|
|
@@ -41,9 +42,18 @@ module ClaudeHooks
|
|
|
41
42
|
r = output_data['reason']
|
|
42
43
|
reasons << r if r && !r.empty?
|
|
43
44
|
end
|
|
45
|
+
|
|
46
|
+
context = output_data.dig('hookSpecificOutput', 'additionalContext')
|
|
47
|
+
contexts << context if context && !context.empty?
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
merged_data['reason'] = reasons.join('; ') if reasons.any?
|
|
51
|
+
unless contexts.empty?
|
|
52
|
+
merged_data['hookSpecificOutput'] = {
|
|
53
|
+
'hookEventName' => 'PostToolBatch',
|
|
54
|
+
'additionalContext' => contexts.join("\n\n")
|
|
55
|
+
}
|
|
56
|
+
end
|
|
47
57
|
new(merged_data)
|
|
48
58
|
end
|
|
49
59
|
end
|
|
@@ -27,6 +27,10 @@ module ClaudeHooks
|
|
|
27
27
|
hook_specific_output['updatedMCPToolOutput']
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
def classifier_context
|
|
31
|
+
hook_specific_output['classifierContext'] || ''
|
|
32
|
+
end
|
|
33
|
+
|
|
30
34
|
# === SEMANTIC HELPERS ===
|
|
31
35
|
|
|
32
36
|
def blocked?
|
|
@@ -64,6 +68,11 @@ module ClaudeHooks
|
|
|
64
68
|
merged = super(*outputs)
|
|
65
69
|
merged_data = merged.data
|
|
66
70
|
contexts = []
|
|
71
|
+
classifier_contexts = []
|
|
72
|
+
# Tool-output rewrites: last non-nil wins (most recent transformation), as with
|
|
73
|
+
# PreToolUse's updatedInput. Dropping them would let e.g. a redacted output
|
|
74
|
+
# revert to the original when composed with another handler.
|
|
75
|
+
rewrites = {}
|
|
67
76
|
|
|
68
77
|
compacted_outputs.each do |output|
|
|
69
78
|
output_data = output.respond_to?(:data) ? output.data : output
|
|
@@ -72,13 +81,22 @@ module ClaudeHooks
|
|
|
72
81
|
|
|
73
82
|
context = output_data.dig('hookSpecificOutput', 'additionalContext')
|
|
74
83
|
contexts << context if context && !context.empty?
|
|
84
|
+
|
|
85
|
+
classifier_context = output_data.dig('hookSpecificOutput', 'classifierContext')
|
|
86
|
+
classifier_contexts << classifier_context if classifier_context && !classifier_context.empty?
|
|
87
|
+
|
|
88
|
+
%w[updatedToolOutput updatedMCPToolOutput].each do |key|
|
|
89
|
+
value = output_data.dig('hookSpecificOutput', key)
|
|
90
|
+
rewrites[key] = value unless value.nil?
|
|
91
|
+
end
|
|
75
92
|
end
|
|
76
93
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
94
|
+
if !contexts.empty? || !classifier_contexts.empty? || !rewrites.empty?
|
|
95
|
+
hso = { 'hookEventName' => 'PostToolUse' }
|
|
96
|
+
hso['additionalContext'] = contexts.join("\n\n") unless contexts.empty?
|
|
97
|
+
hso['classifierContext'] = classifier_contexts.join("\n\n") unless classifier_contexts.empty?
|
|
98
|
+
hso.merge!(rewrites)
|
|
99
|
+
merged_data['hookSpecificOutput'] = hso
|
|
82
100
|
end
|
|
83
101
|
|
|
84
102
|
new(merged_data)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
module Output
|
|
7
|
+
class PreModelSwitch < Base
|
|
8
|
+
# === PERMISSION DECISION ACCESSORS ===
|
|
9
|
+
|
|
10
|
+
def permission_decision
|
|
11
|
+
hook_specific_output['permissionDecision']
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def permission_reason
|
|
15
|
+
hook_specific_output['permissionDecisionReason'] || ''
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# === TOP-LEVEL DECISION ACCESSORS ===
|
|
19
|
+
|
|
20
|
+
def decision
|
|
21
|
+
@data['decision']
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reason
|
|
25
|
+
@data['reason'] || ''
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# === SEMANTIC HELPERS ===
|
|
29
|
+
|
|
30
|
+
def allowed?
|
|
31
|
+
permission_decision == 'allow'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def denied?
|
|
35
|
+
permission_decision == 'deny'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Blocked when denied via permissionDecision OR via top-level decision:block.
|
|
39
|
+
def blocked?
|
|
40
|
+
denied? || decision == 'block'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def should_ask?
|
|
44
|
+
permission_decision == 'ask'
|
|
45
|
+
end
|
|
46
|
+
alias should_ask_permission? should_ask?
|
|
47
|
+
|
|
48
|
+
# === EXIT CODE LOGIC ===
|
|
49
|
+
#
|
|
50
|
+
# PreModelSwitch uses the advanced JSON API with exit code 0.
|
|
51
|
+
# As on PreToolUse, structured JSON with permissionDecision always goes to
|
|
52
|
+
# stdout with exit 0; the decision fields control whether the switch proceeds.
|
|
53
|
+
def exit_code
|
|
54
|
+
0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def output_stream
|
|
58
|
+
:stdout
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# === MERGE HELPER ===
|
|
62
|
+
|
|
63
|
+
def self.merge(*outputs)
|
|
64
|
+
compacted_outputs = outputs.compact
|
|
65
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
|
66
|
+
return super(*outputs) if compacted_outputs.empty?
|
|
67
|
+
|
|
68
|
+
merged = super(*outputs)
|
|
69
|
+
merged_data = merged.data
|
|
70
|
+
|
|
71
|
+
# PreModelSwitch precedence: deny > ask > allow (most restrictive wins).
|
|
72
|
+
# Stays nil when no hook decided, so the merge doesn't invent an 'allow'.
|
|
73
|
+
permission_decision = nil
|
|
74
|
+
permission_reasons = []
|
|
75
|
+
block_reasons = []
|
|
76
|
+
any_block = false
|
|
77
|
+
|
|
78
|
+
compacted_outputs.each do |output|
|
|
79
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
80
|
+
|
|
81
|
+
if output_data['decision'] == 'block'
|
|
82
|
+
any_block = true
|
|
83
|
+
block_reasons << output_data['reason'] if output_data['reason'] && !output_data['reason'].empty?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
current_decision = output_data.dig('hookSpecificOutput', 'permissionDecision')
|
|
87
|
+
case current_decision
|
|
88
|
+
when 'deny'
|
|
89
|
+
permission_decision = 'deny'
|
|
90
|
+
when 'ask'
|
|
91
|
+
permission_decision = 'ask' unless permission_decision == 'deny'
|
|
92
|
+
when 'allow'
|
|
93
|
+
permission_decision ||= 'allow'
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
reason = output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
|
|
97
|
+
permission_reasons << reason if reason && !reason.empty?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A top-level block cancels the switch, so the merged permissionDecision
|
|
101
|
+
# must agree with it instead of saying 'allow'.
|
|
102
|
+
permission_decision = 'deny' if any_block && permission_decision
|
|
103
|
+
|
|
104
|
+
if permission_decision
|
|
105
|
+
merged_data['hookSpecificOutput'] = {
|
|
106
|
+
'hookEventName' => 'PreModelSwitch',
|
|
107
|
+
'permissionDecision' => permission_decision,
|
|
108
|
+
'permissionDecisionReason' => permission_reasons.join('; ')
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
if any_block
|
|
113
|
+
merged_data['decision'] = 'block'
|
|
114
|
+
merged_data['reason'] = block_reasons.join('; ') unless block_reasons.empty?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
new(merged_data)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -111,6 +111,14 @@ module ClaudeHooks
|
|
|
111
111
|
|
|
112
112
|
merged_data['hookSpecificOutput']['updatedInput'] = updated_inputs.last if updated_inputs.any?
|
|
113
113
|
|
|
114
|
+
# Join every handler's additionalContext, as the other context-carrying merges do.
|
|
115
|
+
contexts = compacted_outputs.filter_map do |output|
|
|
116
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
117
|
+
context = output_data.dig('hookSpecificOutput', 'additionalContext')
|
|
118
|
+
context unless context.nil? || context.empty?
|
|
119
|
+
end
|
|
120
|
+
merged_data['hookSpecificOutput']['additionalContext'] = contexts.join("\n\n") if contexts.any?
|
|
121
|
+
|
|
114
122
|
new(merged_data)
|
|
115
123
|
end
|
|
116
124
|
end
|
|
@@ -5,13 +5,46 @@ require_relative 'base'
|
|
|
5
5
|
module ClaudeHooks
|
|
6
6
|
module Output
|
|
7
7
|
class TaskCreated < Base
|
|
8
|
+
# === DECISION ACCESSORS ===
|
|
9
|
+
#
|
|
10
|
+
# Claude Code ignores `continue: false` for TaskCreated; block via exit 2 or
|
|
11
|
+
# a top-level decision:block.
|
|
12
|
+
|
|
13
|
+
def decision
|
|
14
|
+
@data['decision']
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reason
|
|
18
|
+
@data['reason'] || ''
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def blocked?
|
|
22
|
+
decision == 'block'
|
|
23
|
+
end
|
|
24
|
+
|
|
8
25
|
def exit_code
|
|
9
26
|
default_exit_code
|
|
10
27
|
end
|
|
11
28
|
|
|
12
29
|
def self.merge(*outputs)
|
|
30
|
+
compacted_outputs = outputs.compact
|
|
31
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
|
32
|
+
return super(*outputs) if compacted_outputs.empty?
|
|
33
|
+
|
|
13
34
|
merged = super(*outputs)
|
|
14
|
-
|
|
35
|
+
merged_data = merged.data
|
|
36
|
+
reasons = []
|
|
37
|
+
|
|
38
|
+
compacted_outputs.each do |output|
|
|
39
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
40
|
+
merged_data['decision'] = 'block' if output_data['decision'] == 'block'
|
|
41
|
+
reason = output_data['reason']
|
|
42
|
+
reasons << reason if reason && !reason.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
merged_data['reason'] = reasons.join('; ') unless reasons.empty?
|
|
46
|
+
|
|
47
|
+
new(merged_data)
|
|
15
48
|
end
|
|
16
49
|
end
|
|
17
50
|
end
|
|
@@ -33,6 +33,7 @@ module ClaudeHooks
|
|
|
33
33
|
merged = super(*outputs)
|
|
34
34
|
merged_data = merged.data
|
|
35
35
|
reasons = []
|
|
36
|
+
contexts = []
|
|
36
37
|
|
|
37
38
|
compacted_outputs.each do |output|
|
|
38
39
|
output_data = output.respond_to?(:data) ? output.data : output
|
|
@@ -41,9 +42,18 @@ module ClaudeHooks
|
|
|
41
42
|
r = output_data['reason']
|
|
42
43
|
reasons << r if r && !r.empty?
|
|
43
44
|
end
|
|
45
|
+
|
|
46
|
+
context = output_data.dig('hookSpecificOutput', 'additionalContext')
|
|
47
|
+
contexts << context if context && !context.empty?
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
merged_data['reason'] = reasons.join('; ') if reasons.any?
|
|
51
|
+
unless contexts.empty?
|
|
52
|
+
merged_data['hookSpecificOutput'] = {
|
|
53
|
+
'hookEventName' => 'UserPromptExpansion',
|
|
54
|
+
'additionalContext' => contexts.join("\n\n")
|
|
55
|
+
}
|
|
56
|
+
end
|
|
47
57
|
new(merged_data)
|
|
48
58
|
end
|
|
49
59
|
end
|
|
@@ -19,6 +19,14 @@ module ClaudeHooks
|
|
|
19
19
|
hook_specific_output['additionalContext'] || ''
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def session_title
|
|
23
|
+
hook_specific_output['sessionTitle']
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def suppress_original_prompt?
|
|
27
|
+
hook_specific_output['suppressOriginalPrompt'] == true
|
|
28
|
+
end
|
|
29
|
+
|
|
22
30
|
# === SEMANTIC HELPERS ===
|
|
23
31
|
|
|
24
32
|
def blocked?
|
|
@@ -53,22 +61,27 @@ module ClaudeHooks
|
|
|
53
61
|
merged_data = merged.data
|
|
54
62
|
|
|
55
63
|
contexts = []
|
|
64
|
+
specific = {}
|
|
56
65
|
|
|
57
66
|
compacted_outputs.each do |output|
|
|
58
67
|
output_data = output.respond_to?(:data) ? output.data : output
|
|
68
|
+
hso = output_data['hookSpecificOutput'] || {}
|
|
59
69
|
|
|
60
70
|
merged_data['decision'] = 'block' if output_data['decision'] == 'block'
|
|
61
71
|
merged_data['reason'] = [merged_data['reason'], output_data['reason']].compact.reject(&:empty?).join('; ')
|
|
62
72
|
|
|
63
|
-
context =
|
|
73
|
+
context = hso['additionalContext']
|
|
64
74
|
contexts << context if context && !context.empty?
|
|
75
|
+
|
|
76
|
+
# Last non-nil title wins; suppressing the prompt wins if any hook asks for it.
|
|
77
|
+
specific['sessionTitle'] = hso['sessionTitle'] unless hso['sessionTitle'].nil?
|
|
78
|
+
specific['suppressOriginalPrompt'] = true if hso['suppressOriginalPrompt'] == true
|
|
65
79
|
end
|
|
66
80
|
|
|
67
|
-
unless contexts.empty?
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
81
|
+
specific['additionalContext'] = contexts.join("\n\n") unless contexts.empty?
|
|
82
|
+
|
|
83
|
+
unless specific.empty?
|
|
84
|
+
merged_data['hookSpecificOutput'] = { 'hookEventName' => 'UserPromptSubmit' }.merge(specific)
|
|
72
85
|
end
|
|
73
86
|
|
|
74
87
|
new(merged_data)
|
|
@@ -4,10 +4,12 @@ require_relative 'base'
|
|
|
4
4
|
|
|
5
5
|
module ClaudeHooks
|
|
6
6
|
module Output
|
|
7
|
-
# WorktreeRemove
|
|
7
|
+
# WorktreeRemove can block: a non-zero exit code now fails the worktree
|
|
8
|
+
# removal when the directory still exists (Claude Code no longer ignores it).
|
|
9
|
+
# Use prevent_continue!(reason) to block; exit code follows continue (0/2).
|
|
8
10
|
class WorktreeRemove < Base
|
|
9
11
|
def exit_code
|
|
10
|
-
|
|
12
|
+
default_exit_code
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
def self.merge(*outputs)
|
|
@@ -24,6 +24,13 @@ module ClaudeHooks
|
|
|
24
24
|
@input_data['tool_use_id'] || @input_data['toolUseId']
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# For MCP tools: { 'name' => ..., 'source' => ... } describing where the server's
|
|
28
|
+
# definition came from (e.g. 'plugin', 'sdk', 'user', 'project'). Base trust
|
|
29
|
+
# decisions on 'source', not on the name. nil for non-MCP tools. Requires Claude Code v2.1.274+.
|
|
30
|
+
def mcp_server
|
|
31
|
+
@input_data['mcp_server'] || @input_data['mcpServer']
|
|
32
|
+
end
|
|
33
|
+
|
|
27
34
|
def reason
|
|
28
35
|
@input_data['reason']
|
|
29
36
|
end
|
|
@@ -9,7 +9,7 @@ module ClaudeHooks
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def self.input_fields
|
|
12
|
-
%w[tool_name tool_input
|
|
12
|
+
%w[tool_name tool_input]
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
# === INPUT DATA ACCESS ===
|
|
@@ -22,10 +22,19 @@ module ClaudeHooks
|
|
|
22
22
|
@input_data['tool_input']
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# DEPRECATED: Claude Code doesn't send tool_use_id to PermissionRequest hooks, so this
|
|
26
|
+
# is always nil. Kept for backward compatibility; not part of input_fields.
|
|
25
27
|
def tool_use_id
|
|
26
28
|
@input_data['tool_use_id'] || @input_data['toolUseId']
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# For MCP tools: { 'name' => ..., 'source' => ... } describing where the server's
|
|
32
|
+
# definition came from (e.g. 'plugin', 'sdk', 'user', 'project'). Base trust
|
|
33
|
+
# decisions on 'source', not on the name. nil for non-MCP tools. Requires Claude Code v2.1.274+.
|
|
34
|
+
def mcp_server
|
|
35
|
+
@input_data['mcp_server'] || @input_data['mcpServer']
|
|
36
|
+
end
|
|
37
|
+
|
|
29
38
|
def permission_suggestions
|
|
30
39
|
@input_data['permissionSuggestions'] || @input_data['permission_suggestions'] || []
|
|
31
40
|
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
class PostModelSwitch < Base
|
|
7
|
+
def self.hook_type
|
|
8
|
+
'PostModelSwitch'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.input_fields
|
|
12
|
+
%w[from_model to_model requested_model source]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# === INPUT DATA ACCESS ===
|
|
16
|
+
#
|
|
17
|
+
# PostModelSwitch receives the same fields as PreModelSwitch, with two extra
|
|
18
|
+
# `source` values ("auto", "resume"). `requested_model` is null when source is
|
|
19
|
+
# "auto", and the restored setting when source is "resume".
|
|
20
|
+
|
|
21
|
+
def from_model
|
|
22
|
+
@input_data['from_model'] || @input_data['fromModel']
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_model
|
|
26
|
+
@input_data['to_model'] || @input_data['toModel']
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def requested_model
|
|
30
|
+
@input_data['requested_model'] || @input_data['requestedModel']
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def source
|
|
34
|
+
@input_data['source']
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def context_tokens
|
|
38
|
+
@input_data['context_tokens'] || @input_data['contextTokens']
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def prompt_cache_warm
|
|
42
|
+
@input_data.key?('prompt_cache_warm') ? @input_data['prompt_cache_warm'] : @input_data['promptCacheWarm']
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def cache_ttl
|
|
46
|
+
@input_data['cache_ttl'] || @input_data['cacheTtl']
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def estimated_cache_write_usd
|
|
50
|
+
@input_data['estimated_cache_write_usd'] || @input_data['estimatedCacheWriteUsd']
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def pricing
|
|
54
|
+
@input_data['pricing']
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# === OUTPUT DATA HELPERS ===
|
|
58
|
+
#
|
|
59
|
+
# PostModelSwitch can't block (the model already changed); it only adds context.
|
|
60
|
+
# Its stdout is also shown to Claude as context.
|
|
61
|
+
|
|
62
|
+
def add_additional_context!(context)
|
|
63
|
+
@output_data['hookSpecificOutput'] = {
|
|
64
|
+
'hookEventName' => hook_event_name,
|
|
65
|
+
'additionalContext' => context
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
alias_method :add_context!, :add_additional_context!
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -30,6 +30,13 @@ module ClaudeHooks
|
|
|
30
30
|
@input_data['tool_use_id'] || @input_data['toolUseId']
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# For MCP tools: { 'name' => ..., 'source' => ... } describing where the server's
|
|
34
|
+
# definition came from (e.g. 'plugin', 'sdk', 'user', 'project'). Base trust
|
|
35
|
+
# decisions on 'source', not on the name. nil for non-MCP tools. Requires Claude Code v2.1.274+.
|
|
36
|
+
def mcp_server
|
|
37
|
+
@input_data['mcp_server'] || @input_data['mcpServer']
|
|
38
|
+
end
|
|
39
|
+
|
|
33
40
|
# === OUTPUT DATA HELPERS ===
|
|
34
41
|
|
|
35
42
|
def block_tool!(reason = '')
|
|
@@ -59,5 +66,12 @@ module ClaudeHooks
|
|
|
59
66
|
@output_data['hookSpecificOutput'] ||= { 'hookEventName' => hook_event_name }
|
|
60
67
|
@output_data['hookSpecificOutput']['updatedMCPToolOutput'] = value
|
|
61
68
|
end
|
|
69
|
+
|
|
70
|
+
# Annotate this call's result for the auto-mode classifier (not for Claude).
|
|
71
|
+
# Requires Claude Code v2.1.236 or later.
|
|
72
|
+
def classifier_context!(note)
|
|
73
|
+
@output_data['hookSpecificOutput'] ||= { 'hookEventName' => hook_event_name }
|
|
74
|
+
@output_data['hookSpecificOutput']['classifierContext'] = note
|
|
75
|
+
end
|
|
62
76
|
end
|
|
63
77
|
end
|
|
@@ -24,6 +24,13 @@ module ClaudeHooks
|
|
|
24
24
|
@input_data['tool_use_id'] || @input_data['toolUseId']
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# For MCP tools: { 'name' => ..., 'source' => ... } describing where the server's
|
|
28
|
+
# definition came from (e.g. 'plugin', 'sdk', 'user', 'project'). Base trust
|
|
29
|
+
# decisions on 'source', not on the name. nil for non-MCP tools. Requires Claude Code v2.1.274+.
|
|
30
|
+
def mcp_server
|
|
31
|
+
@input_data['mcp_server'] || @input_data['mcpServer']
|
|
32
|
+
end
|
|
33
|
+
|
|
27
34
|
def error
|
|
28
35
|
@input_data['error']
|
|
29
36
|
end
|