rubyn-code 0.5.0 → 0.7.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/README.md +182 -11
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +89 -3
- data/lib/rubyn_code/agent/llm_caller.rb +2 -2
- data/lib/rubyn_code/agent/loop.rb +49 -9
- data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
- data/lib/rubyn_code/agent/tool_processor.rb +3 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/checkpoint/hook.rb +26 -0
- data/lib/rubyn_code/checkpoint/manager.rb +109 -0
- data/lib/rubyn_code/chisel/debt.rb +65 -0
- data/lib/rubyn_code/chisel/inspection.rb +93 -0
- data/lib/rubyn_code/chisel.rb +127 -0
- data/lib/rubyn_code/cli/app.rb +2 -2
- data/lib/rubyn_code/cli/commands/agents.rb +31 -0
- data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
- data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
- data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
- data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
- data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
- data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
- data/lib/rubyn_code/cli/commands/context.rb +3 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
- data/lib/rubyn_code/cli/commands/goal.rb +87 -0
- data/lib/rubyn_code/cli/commands/learning.rb +62 -0
- data/lib/rubyn_code/cli/commands/loop.rb +58 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
- data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/loop_runner.rb +98 -0
- data/lib/rubyn_code/cli/mention_expander.rb +92 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +37 -14
- data/lib/rubyn_code/cli/repl_commands.rb +77 -2
- data/lib/rubyn_code/cli/repl_setup.rb +9 -1
- data/lib/rubyn_code/cli/setup.rb +13 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
- data/lib/rubyn_code/cli/version_check.rb +10 -3
- data/lib/rubyn_code/config/defaults.rb +13 -1
- data/lib/rubyn_code/config/schema.json +4 -0
- data/lib/rubyn_code/config/settings.rb +17 -2
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/debug.rb +11 -5
- data/lib/rubyn_code/goal/evaluator.rb +95 -0
- data/lib/rubyn_code/hooks/event_map.rb +56 -0
- data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
- data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
- data/lib/rubyn_code/hooks/response.rb +83 -0
- data/lib/rubyn_code/hooks/runner.rb +61 -3
- data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
- data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +143 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers.rb +17 -2
- data/lib/rubyn_code/ide/protocol.rb +15 -0
- data/lib/rubyn_code/ide/server.rb +39 -1
- data/lib/rubyn_code/index/codebase_index.rb +39 -1
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
- data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
- data/lib/rubyn_code/llm/model_router.rb +2 -2
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
- data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
- data/lib/rubyn_code/megaplan/interview_session.rb +250 -0
- data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
- data/lib/rubyn_code/memory/search.rb +9 -5
- data/lib/rubyn_code/memory/session_persistence.rb +159 -21
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
- data/lib/rubyn_code/skills/registry_client.rb +4 -3
- data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
- data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
- data/lib/rubyn_code/teams/agent_registry.rb +120 -0
- data/lib/rubyn_code/teams/mailbox.rb +99 -10
- data/lib/rubyn_code/teams/manager.rb +83 -5
- data/lib/rubyn_code/teams/teammate.rb +5 -1
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/executor.rb +5 -3
- data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
- data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
- data/lib/rubyn_code/tools/web_fetch.rb +1 -1
- data/lib/rubyn_code/tools/web_search.rb +4 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +53 -2
- data/skills/megaplan/megaplan.md +156 -0
- data/skills/rubyn_self_test.md +322 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +49 -4
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'event_map'
|
|
4
|
+
require_relative 'response'
|
|
5
|
+
require_relative 'settings_json_loader'
|
|
6
|
+
require_relative 'subprocess_executor'
|
|
7
|
+
|
|
8
|
+
module RubynCode
|
|
9
|
+
module Hooks
|
|
10
|
+
# Dispatches hook events to external commands configured in settings.json.
|
|
11
|
+
#
|
|
12
|
+
# The dispatcher sits alongside the in-process Hooks::Runner. It does NOT
|
|
13
|
+
# replace it — existing pre/post_tool_use YAML hooks and Ruby callables
|
|
14
|
+
# keep working. New code that wants Claude Code-style control flow (block,
|
|
15
|
+
# stopReason, additionalContext) fires through this dispatcher instead.
|
|
16
|
+
#
|
|
17
|
+
# Wire it into the agent loop wherever a return value matters:
|
|
18
|
+
#
|
|
19
|
+
# response = dispatcher.fire(:pre_tool_use, tool_name:, tool_input:)
|
|
20
|
+
# raise ToolBlockedError, response.reason if response.block?
|
|
21
|
+
#
|
|
22
|
+
# For events where return values don't matter (e.g. SessionStart logging),
|
|
23
|
+
# call #fire and ignore the response — but still inspect #stop? when the
|
|
24
|
+
# caller wants to honour a stop signal mid-stream.
|
|
25
|
+
class ExternalDispatcher
|
|
26
|
+
DEFAULT_TIMEOUT = 60
|
|
27
|
+
|
|
28
|
+
attr_reader :project_root, :config
|
|
29
|
+
|
|
30
|
+
# @param project_root [String]
|
|
31
|
+
# @param config [Hash<String, Array<Hash>>] result of SettingsJsonLoader#load
|
|
32
|
+
# @param executor [SubprocessExecutor, nil] injectable for tests
|
|
33
|
+
# @param logger [#warn, nil] injectable for tests
|
|
34
|
+
def initialize(project_root:, config: nil, executor: nil, logger: nil)
|
|
35
|
+
@project_root = project_root
|
|
36
|
+
@config = config || SettingsJsonLoader.new(project_root: project_root).load
|
|
37
|
+
@executor = executor || SubprocessExecutor.new(project_root: project_root)
|
|
38
|
+
@logger = logger || method(:default_log)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Boolean] true if any external hook is configured for this event
|
|
42
|
+
def configured_for?(internal_event)
|
|
43
|
+
external = EventMap.external(internal_event)
|
|
44
|
+
return false unless external
|
|
45
|
+
|
|
46
|
+
Array(@config[external]).any?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Fires all configured external hooks for the given internal event.
|
|
50
|
+
#
|
|
51
|
+
# Each matcher group's commands are run sequentially (the order they
|
|
52
|
+
# appear in settings.json). Within a group, commands run in declared
|
|
53
|
+
# order. Hook errors and timeouts are logged but do not abort the
|
|
54
|
+
# remaining hooks — matching Claude Code's "best effort" semantics.
|
|
55
|
+
#
|
|
56
|
+
# @param internal_event [Symbol] one of TO_EXTERNAL keys
|
|
57
|
+
# @param payload [Hash] event-specific payload (tool_name, tool_input, etc.)
|
|
58
|
+
# @return [Response] the merged response from all hooks (first block/stop
|
|
59
|
+
# wins; additionalContext is concatenated)
|
|
60
|
+
def fire(internal_event, **payload)
|
|
61
|
+
external = EventMap.external(internal_event)
|
|
62
|
+
return empty_response unless external
|
|
63
|
+
|
|
64
|
+
groups = Array(@config[external])
|
|
65
|
+
return empty_response if groups.empty?
|
|
66
|
+
|
|
67
|
+
envelope = build_envelope(external, payload)
|
|
68
|
+
collected = collect_responses(groups, envelope, payload)
|
|
69
|
+
Response.new(raw: build_merged(collected, external))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def empty_response
|
|
75
|
+
Response.new(raw: {})
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Runs each configured hook command and collects its decision fields.
|
|
79
|
+
# First block/stop wins; additionalContext/suppressOutput are also
|
|
80
|
+
# first-wins for simplicity (matches Claude Code's documented behaviour).
|
|
81
|
+
def collect_responses(groups, envelope, payload)
|
|
82
|
+
accumulator = {
|
|
83
|
+
block_reason: nil, stop_reason: nil,
|
|
84
|
+
additional_context: nil, suppress_output: false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
groups.each do |group|
|
|
88
|
+
next unless matches?(group['matcher'], payload)
|
|
89
|
+
|
|
90
|
+
group['hooks'].each do |command_cfg|
|
|
91
|
+
response = invoke_command(command_cfg, envelope)
|
|
92
|
+
merge_response!(accumulator, response)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
accumulator
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def merge_response!(accumulator, response)
|
|
100
|
+
return unless response
|
|
101
|
+
|
|
102
|
+
accumulator[:block_reason] ||= response.reason if response.block?
|
|
103
|
+
accumulator[:stop_reason] ||= response.stop_reason if response.stop?
|
|
104
|
+
accumulator[:additional_context] ||= response.additional_context if response.additional_context?
|
|
105
|
+
accumulator[:suppress_output] ||= response.suppress_output?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def invoke_command(command_cfg, envelope)
|
|
109
|
+
command = command_cfg['command']
|
|
110
|
+
return nil if command.nil? || command.empty?
|
|
111
|
+
|
|
112
|
+
timeout = command_cfg['timeout'] || DEFAULT_TIMEOUT
|
|
113
|
+
env = command_cfg['env'] || {}
|
|
114
|
+
|
|
115
|
+
raw = @executor.run(
|
|
116
|
+
command: command,
|
|
117
|
+
env: env,
|
|
118
|
+
payload: envelope,
|
|
119
|
+
timeout: timeout
|
|
120
|
+
)
|
|
121
|
+
Response.new(raw: raw || {})
|
|
122
|
+
rescue SubprocessExecutor::TimeoutError, SubprocessExecutor::ExecutionError => e
|
|
123
|
+
@logger.call("[ExternalDispatcher] hook '#{command}' failed: #{e.message}")
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def build_envelope(external_event, payload)
|
|
128
|
+
base_envelope(external_event, payload).merge(event_specific_fields(external_event, payload)).compact
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def base_envelope(external_event, payload)
|
|
132
|
+
{
|
|
133
|
+
'hookEventName' => external_event,
|
|
134
|
+
'sessionId' => payload[:session_id] || payload['session_id'] || ENV.fetch('RUBYN_SESSION_ID', nil),
|
|
135
|
+
'cwd' => @project_root,
|
|
136
|
+
'transcriptPath' => payload[:transcript_path] || ENV.fetch('RUBYN_TRANSCRIPT_PATH', nil)
|
|
137
|
+
}.compact
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def event_specific_fields(external_event, payload)
|
|
141
|
+
case external_event
|
|
142
|
+
when 'PreToolUse', 'PostToolUse' then tool_envelope_fields(payload)
|
|
143
|
+
when 'UserPromptSubmit' then { 'prompt' => payload[:prompt] || payload['text'] }
|
|
144
|
+
when 'Notification' then { 'message' => payload[:message] }
|
|
145
|
+
when 'SessionStart', 'SessionEnd', 'Stop', 'SubagentStop' then session_envelope_fields(payload)
|
|
146
|
+
when 'PreCompact' then { 'trigger' => payload[:trigger] || 'auto' }
|
|
147
|
+
else {}
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def tool_envelope_fields(payload)
|
|
152
|
+
{
|
|
153
|
+
'toolName' => payload[:tool_name] || payload['tool_name'],
|
|
154
|
+
'toolInput' => payload[:tool_input] || payload['tool_input'] || {}
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def session_envelope_fields(payload)
|
|
159
|
+
return {} unless payload[:reason]
|
|
160
|
+
|
|
161
|
+
{ 'reason' => payload[:reason] }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def matches?(matcher, payload)
|
|
165
|
+
return true if matcher.nil? || matcher == '*'
|
|
166
|
+
|
|
167
|
+
target = payload[:tool_name] || payload['tool_name'] || payload[:session_id] || payload['session_id']
|
|
168
|
+
return true if target.nil? # No subject to match — accept all
|
|
169
|
+
|
|
170
|
+
begin
|
|
171
|
+
Regexp.new(matcher).match?(target.to_s)
|
|
172
|
+
rescue RegexpError
|
|
173
|
+
# Fall back to literal equality if matcher isn't a valid regex.
|
|
174
|
+
matcher.to_s == target.to_s
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def build_merged(collected, hook_event_name)
|
|
179
|
+
merged = {}
|
|
180
|
+
merged['decision'] = 'block' if collected[:block_reason]
|
|
181
|
+
merged['reason'] = collected[:block_reason] if collected[:block_reason]
|
|
182
|
+
merged['stopReason'] = collected[:stop_reason] if collected[:stop_reason]
|
|
183
|
+
merged['continue'] = false if collected[:stop_reason]
|
|
184
|
+
merged['suppressOutput'] = true if collected[:suppress_output]
|
|
185
|
+
return merged unless collected[:additional_context]
|
|
186
|
+
|
|
187
|
+
merged['hookSpecificOutput'] = {
|
|
188
|
+
'hookEventName' => hook_event_name,
|
|
189
|
+
'additionalContext' => collected[:additional_context]
|
|
190
|
+
}
|
|
191
|
+
merged
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def default_log(message)
|
|
195
|
+
warn message
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Hooks
|
|
5
|
+
# A Stop hook that keeps the agent working until a session goal is met.
|
|
6
|
+
#
|
|
7
|
+
# When active, firing the :stop event asks an evaluator whether the goal
|
|
8
|
+
# condition is satisfied. If it is, the hook deactivates itself (the goal
|
|
9
|
+
# "auto-clears") and allows the agent to stop. If not, it returns a block
|
|
10
|
+
# decision whose reason is re-injected into the conversation, nudging the
|
|
11
|
+
# agent to keep working.
|
|
12
|
+
#
|
|
13
|
+
# A max-attempts safety valve prevents an unsatisfiable goal from looping
|
|
14
|
+
# forever — after MAX_ATTEMPTS consecutive blocks the hook gives up and
|
|
15
|
+
# lets the agent stop.
|
|
16
|
+
class GoalHook
|
|
17
|
+
DEFAULT_MAX_ATTEMPTS = 12
|
|
18
|
+
|
|
19
|
+
# @return [String] the goal condition
|
|
20
|
+
attr_reader :condition
|
|
21
|
+
|
|
22
|
+
# @param condition [String] plain-language goal condition
|
|
23
|
+
# @param evaluator [#call, nil] judges completion; nil disables auto-clear
|
|
24
|
+
# @param max_attempts [Integer] consecutive blocks before giving up
|
|
25
|
+
def initialize(condition:, evaluator: nil, max_attempts: DEFAULT_MAX_ATTEMPTS)
|
|
26
|
+
@condition = condition
|
|
27
|
+
@evaluator = evaluator
|
|
28
|
+
@max_attempts = max_attempts
|
|
29
|
+
@attempts = 0
|
|
30
|
+
@active = true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def active? = @active
|
|
35
|
+
|
|
36
|
+
# Cancel the goal early (e.g. `/goal clear`).
|
|
37
|
+
# @return [void]
|
|
38
|
+
def clear!
|
|
39
|
+
@active = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Fired on the :stop event by Hooks::Runner.
|
|
43
|
+
#
|
|
44
|
+
# @param conversation [Agent::Conversation, nil] recent work, for judging
|
|
45
|
+
# @return [Hash, nil] { block: true, reason: } to keep working, else nil
|
|
46
|
+
def call(conversation: nil, **_kwargs)
|
|
47
|
+
return nil unless @active
|
|
48
|
+
|
|
49
|
+
if goal_met?(conversation)
|
|
50
|
+
@active = false
|
|
51
|
+
return nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@attempts += 1
|
|
55
|
+
if @attempts >= @max_attempts
|
|
56
|
+
@active = false
|
|
57
|
+
RubynCode::Debug.warn("Goal abandoned after #{@max_attempts} attempts: #{@condition}")
|
|
58
|
+
return nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
{ block: true, reason: reminder }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def goal_met?(conversation)
|
|
67
|
+
return false unless @evaluator
|
|
68
|
+
|
|
69
|
+
@evaluator.call(condition: @condition, conversation: conversation)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
RubynCode::Debug.warn("Goal hook evaluation error: #{e.message}")
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def reminder
|
|
76
|
+
<<~TEXT.strip
|
|
77
|
+
Your active goal is not yet complete:
|
|
78
|
+
|
|
79
|
+
#{@condition}
|
|
80
|
+
|
|
81
|
+
Keep working toward it now — do not stop or ask what to do next.
|
|
82
|
+
If you are certain the goal is genuinely and fully met, state that
|
|
83
|
+
explicitly and explain how it was satisfied.
|
|
84
|
+
TEXT
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Hooks
|
|
5
|
+
# Normalized response from an external (Claude Code-style) hook.
|
|
6
|
+
#
|
|
7
|
+
# External hooks communicate control-flow decisions and prompt augmentations
|
|
8
|
+
# back to the agent via JSON. This class wraps the parsed response and
|
|
9
|
+
# provides predicate methods so call sites don't have to know the response
|
|
10
|
+
# shape details.
|
|
11
|
+
#
|
|
12
|
+
# Supported response shapes (any subset can be combined):
|
|
13
|
+
#
|
|
14
|
+
# { "continue": false, "stopReason": "Denied by policy" }
|
|
15
|
+
# — ask the agent to stop. stopReason is appended to the conversation.
|
|
16
|
+
#
|
|
17
|
+
# { "decision": "block", "reason": "rm -rf is forbidden" }
|
|
18
|
+
# — for PreToolUse only; abort this tool call before it runs.
|
|
19
|
+
#
|
|
20
|
+
# { "decision": "approve" } or { "decision": undefined }
|
|
21
|
+
# — allow the call to proceed (default).
|
|
22
|
+
#
|
|
23
|
+
# {
|
|
24
|
+
# "hookSpecificOutput": {
|
|
25
|
+
# "hookEventName": "PreToolUse",
|
|
26
|
+
# "additionalContext": "Project policy: always run rubocop before commit"
|
|
27
|
+
# }
|
|
28
|
+
# }
|
|
29
|
+
# — additionalContext is injected into the next system prompt / LLM
|
|
30
|
+
# request so the model sees the hook's guidance.
|
|
31
|
+
#
|
|
32
|
+
# { "suppressOutput": true }
|
|
33
|
+
# — UI hook wants to silence default output rendering (e.g. streamed
|
|
34
|
+
# json progress). Best-effort; honoured by the renderer when present.
|
|
35
|
+
class Response
|
|
36
|
+
# @return [String, nil] reason text for block/stop decisions
|
|
37
|
+
attr_reader :reason
|
|
38
|
+
|
|
39
|
+
# @return [String, nil] additionalContext injected into the next LLM call
|
|
40
|
+
attr_reader :additional_context
|
|
41
|
+
|
|
42
|
+
attr_reader :hook_event_name, :stop_reason
|
|
43
|
+
|
|
44
|
+
def initialize(raw: {})
|
|
45
|
+
@raw = raw || {}
|
|
46
|
+
@decision = @raw['decision']
|
|
47
|
+
@continue = @raw.key?('continue') ? @raw['continue'] : true
|
|
48
|
+
@stop_reason = @raw['stopReason']
|
|
49
|
+
@reason = @raw['reason']
|
|
50
|
+
@suppress = @raw['suppressOutput'] == true
|
|
51
|
+
|
|
52
|
+
specific = @raw['hookSpecificOutput'].is_a?(Hash) ? @raw['hookSpecificOutput'] : {}
|
|
53
|
+
@hook_event_name = specific['hookEventName']
|
|
54
|
+
@additional_context = specific['additionalContext']
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] true if the hook says to abort a PreToolUse call
|
|
58
|
+
def block?
|
|
59
|
+
@decision == 'block'
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Boolean] true if the hook says to stop the agent entirely
|
|
63
|
+
def stop?
|
|
64
|
+
@continue == false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Boolean] true if the hook has context to inject
|
|
68
|
+
def additional_context?
|
|
69
|
+
!@additional_context.nil? && !@additional_context.to_s.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Boolean] true if the hook wants output suppressed
|
|
73
|
+
def suppress_output?
|
|
74
|
+
@suppress
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Hash] the raw JSON response (for debugging/logging)
|
|
78
|
+
def to_h
|
|
79
|
+
@raw
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -8,10 +8,18 @@ module RubynCode
|
|
|
8
8
|
# caught and logged rather than allowed to crash the agent. Special
|
|
9
9
|
# semantics apply to :pre_tool_use (deny gating) and :post_tool_use
|
|
10
10
|
# (output transformation).
|
|
11
|
+
#
|
|
12
|
+
# The runner can also fan out to an external hook dispatcher (see
|
|
13
|
+
# ExternalDispatcher) which spawns Claude Code-style hook commands
|
|
14
|
+
# configured in settings.json. External hooks participate in the same
|
|
15
|
+
# deny/block decisions as in-process hooks.
|
|
11
16
|
class Runner
|
|
12
17
|
# @param registry [Hooks::Registry] the hook registry to draw from
|
|
13
|
-
|
|
18
|
+
# @param external_dispatcher [Hooks::ExternalDispatcher, nil] optional
|
|
19
|
+
# dispatcher for Claude Code-style external hook commands
|
|
20
|
+
def initialize(registry: Registry.new, external_dispatcher: nil)
|
|
14
21
|
@registry = registry
|
|
22
|
+
@external_dispatcher = external_dispatcher
|
|
15
23
|
end
|
|
16
24
|
|
|
17
25
|
# Fires all hooks for the given event with the supplied context.
|
|
@@ -24,20 +32,70 @@ module RubynCode
|
|
|
24
32
|
# - all others => nil
|
|
25
33
|
def fire(event, **context)
|
|
26
34
|
hooks = @registry.hooks_for(event)
|
|
27
|
-
|
|
35
|
+
external_response = fire_external(event, **context)
|
|
28
36
|
|
|
29
37
|
case event
|
|
30
38
|
when :pre_tool_use
|
|
31
|
-
fire_pre_tool_use(hooks, context)
|
|
39
|
+
merge_pre_tool_use(fire_pre_tool_use(hooks, context), external_response)
|
|
32
40
|
when :post_tool_use
|
|
41
|
+
# External hooks contribute additionalContext (read by the LLM
|
|
42
|
+
# caller) but do not transform tool output — that's a job for
|
|
43
|
+
# in-process hooks only.
|
|
33
44
|
fire_post_tool_use(hooks, context)
|
|
45
|
+
when :stop
|
|
46
|
+
merge_stop(fire_stop(hooks, context), external_response)
|
|
34
47
|
else
|
|
35
48
|
fire_generic(hooks, event, context)
|
|
49
|
+
external_response&.additional_context
|
|
36
50
|
end
|
|
37
51
|
end
|
|
38
52
|
|
|
39
53
|
private
|
|
40
54
|
|
|
55
|
+
# @return [Hooks::Response, nil] nil when no external dispatcher or
|
|
56
|
+
# no hooks are configured for this event.
|
|
57
|
+
def fire_external(event, **context)
|
|
58
|
+
return nil unless @external_dispatcher
|
|
59
|
+
|
|
60
|
+
@external_dispatcher.fire(event, **context)
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
warn "[RubynCode::Hooks] External dispatcher error during #{event}: #{e.class}: #{e.message}"
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# In-process deny takes precedence over external block. Either way,
|
|
67
|
+
# if any source denies, return a deny hash.
|
|
68
|
+
def merge_pre_tool_use(in_process_result, external_response)
|
|
69
|
+
return in_process_result if in_process_result.is_a?(Hash) && in_process_result[:deny]
|
|
70
|
+
|
|
71
|
+
return nil unless external_response&.block?
|
|
72
|
+
|
|
73
|
+
{ deny: true, reason: external_response.reason || 'Blocked by external hook' }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Same precedence for stop: in-process block wins; external stop otherwise.
|
|
77
|
+
def merge_stop(in_process_result, external_response)
|
|
78
|
+
return in_process_result if in_process_result.is_a?(Hash) && in_process_result[:block]
|
|
79
|
+
|
|
80
|
+
return nil unless external_response&.stop?
|
|
81
|
+
|
|
82
|
+
{ block: true, reason: external_response.stop_reason || 'Stopped by external hook' }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# For :stop, if any hook returns a hash with { block: true }, execution
|
|
86
|
+
# stops and the block result is returned — signalling the agent loop to
|
|
87
|
+
# keep working instead of finalizing (e.g. an active /goal).
|
|
88
|
+
def fire_stop(hooks, context)
|
|
89
|
+
hooks.each do |hook|
|
|
90
|
+
result = safe_call(hook, :stop, context)
|
|
91
|
+
next unless result.is_a?(Hash) && result[:block]
|
|
92
|
+
|
|
93
|
+
return { block: true, reason: result[:reason] || 'Stop blocked by hook' }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
41
99
|
# For :pre_tool_use, if any hook returns a hash with { deny: true },
|
|
42
100
|
# execution stops and the deny result is returned immediately.
|
|
43
101
|
def fire_pre_tool_use(hooks, context)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Hooks
|
|
7
|
+
# Loads external hook commands from a Claude Code-compatible settings.json.
|
|
8
|
+
#
|
|
9
|
+
# Schema (matches Claude Code's hook config):
|
|
10
|
+
#
|
|
11
|
+
# {
|
|
12
|
+
# "hooks": {
|
|
13
|
+
# "PreToolUse": [
|
|
14
|
+
# {
|
|
15
|
+
# "matcher": "bash|write_file", // regex or "*"
|
|
16
|
+
# "hooks": [
|
|
17
|
+
# { "type": "command",
|
|
18
|
+
# "command": "/usr/local/bin/policy-check",
|
|
19
|
+
# "timeout": 60,
|
|
20
|
+
# "env": { "FOO": "bar" } // optional
|
|
21
|
+
# }
|
|
22
|
+
# ]
|
|
23
|
+
# }
|
|
24
|
+
# ],
|
|
25
|
+
# "PostToolUse": [ ... ],
|
|
26
|
+
# "UserPromptSubmit": [ ... ],
|
|
27
|
+
# "SessionStart": [ ... ],
|
|
28
|
+
# "SessionEnd": [ ... ],
|
|
29
|
+
# "Stop": [ ... ],
|
|
30
|
+
# "SubagentStop": [ ... ],
|
|
31
|
+
# "PreCompact": [ ... ],
|
|
32
|
+
# "Notification": [ ... ]
|
|
33
|
+
# }
|
|
34
|
+
# }
|
|
35
|
+
#
|
|
36
|
+
# "matcher" may be:
|
|
37
|
+
# - a regex string matched against the tool name (PreToolUse/PostToolUse)
|
|
38
|
+
# or the session id (other events accept any value);
|
|
39
|
+
# - "*" to match everything;
|
|
40
|
+
# - omitted/null to match everything.
|
|
41
|
+
#
|
|
42
|
+
# The loader does not validate that commands exist on disk — that's the
|
|
43
|
+
# Executor's job (it will fail at fire time with a clear error).
|
|
44
|
+
class SettingsJsonLoader
|
|
45
|
+
class LoadError < RubynCode::Error
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Array<String>] paths the loader will try, in order
|
|
49
|
+
attr_reader :search_paths
|
|
50
|
+
|
|
51
|
+
# @param project_root [String] used to locate .rubyn-code/settings.json
|
|
52
|
+
# @param home_dir [String, nil] override for ~/.rubyn-code; defaults to Defaults::HOME_DIR
|
|
53
|
+
def initialize(project_root:, home_dir: nil)
|
|
54
|
+
@home_dir = home_dir || Config::Defaults::HOME_DIR
|
|
55
|
+
@project_root = project_root
|
|
56
|
+
@search_paths = [
|
|
57
|
+
File.join(@project_root, '.rubyn-code', 'settings.json'),
|
|
58
|
+
File.join(@home_dir, 'settings.json')
|
|
59
|
+
].freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Loads and merges settings.json from project + global.
|
|
63
|
+
#
|
|
64
|
+
# Project wins for the same matcher/event combination: if both files
|
|
65
|
+
# define a hook for the same event, the project's hook runs first and
|
|
66
|
+
# the global hook runs after, mirroring Claude Code's behaviour.
|
|
67
|
+
#
|
|
68
|
+
# @return [Hash<String, Array<Hash>>] { event_name => [matcher_group, ...] }
|
|
69
|
+
# where each matcher_group is { "matcher" => String, "hooks" => [command_hash, ...] }
|
|
70
|
+
def load
|
|
71
|
+
merged = {}
|
|
72
|
+
@search_paths.each do |path|
|
|
73
|
+
next unless File.exist?(path)
|
|
74
|
+
|
|
75
|
+
data = parse_file(path)
|
|
76
|
+
merge_into!(merged, data['hooks'] || {})
|
|
77
|
+
end
|
|
78
|
+
merged
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def parse_file(path)
|
|
84
|
+
JSON.parse(File.read(path))
|
|
85
|
+
rescue JSON::ParserError => e
|
|
86
|
+
raise LoadError, "Failed to parse hook settings at #{path}: #{e.message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def merge_into!(merged, hooks_section)
|
|
90
|
+
hooks_section.each do |event_name, matcher_groups|
|
|
91
|
+
next unless matcher_groups.is_a?(Array)
|
|
92
|
+
|
|
93
|
+
merged[event_name] ||= []
|
|
94
|
+
matcher_groups.each do |group|
|
|
95
|
+
next unless group.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
commands = Array(group['hooks']).select { |h| h.is_a?(Hash) && h['type'] == 'command' }
|
|
98
|
+
next if commands.empty?
|
|
99
|
+
|
|
100
|
+
merged[event_name] << {
|
|
101
|
+
'matcher' => group['matcher'] || '*',
|
|
102
|
+
'hooks' => commands
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module Hooks
|
|
9
|
+
# Spawns external hook commands and exchanges JSON with them.
|
|
10
|
+
#
|
|
11
|
+
# Protocol (matches Claude Code):
|
|
12
|
+
# 1. Spawn the command with { env, chdir: project_root }.
|
|
13
|
+
# 2. Write one JSON line to stdin:
|
|
14
|
+
# { "hookEventName": "PreToolUse",
|
|
15
|
+
# "sessionId": "...",
|
|
16
|
+
# "toolName": "bash", // when applicable
|
|
17
|
+
# "toolInput": { ... }, // when applicable
|
|
18
|
+
# "prompt": "user text..." } // when applicable
|
|
19
|
+
# 3. Close stdin.
|
|
20
|
+
# 4. Read stdout until EOF or timeout. Parse as JSON.
|
|
21
|
+
# - One JSON object spanning the whole output, OR
|
|
22
|
+
# - Newline-delimited JSON (first parseable line wins).
|
|
23
|
+
# 5. Stderr is captured and logged but not parsed.
|
|
24
|
+
#
|
|
25
|
+
# The executor is stateless — each call spawns a fresh process. This is
|
|
26
|
+
# intentional: hooks must not keep state between invocations, and process
|
|
27
|
+
# startup cost (~30ms on macOS) is negligible compared to typical tool
|
|
28
|
+
# execution time.
|
|
29
|
+
class SubprocessExecutor
|
|
30
|
+
DEFAULT_TIMEOUT = 60 # seconds
|
|
31
|
+
|
|
32
|
+
class ExecutionError < RubynCode::Error
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class TimeoutError < ExecutionError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param project_root [String] working directory for spawned processes
|
|
39
|
+
# @param default_timeout [Integer] fallback timeout when a hook entry
|
|
40
|
+
# does not specify its own
|
|
41
|
+
def initialize(project_root:, default_timeout: DEFAULT_TIMEOUT)
|
|
42
|
+
@project_root = project_root
|
|
43
|
+
@default_timeout = default_timeout
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Runs a single hook command with the given event payload.
|
|
47
|
+
#
|
|
48
|
+
# @param command [String] executable path
|
|
49
|
+
# @param args [Array<String>] arguments (rarely used; settings.json
|
|
50
|
+
# typically embeds everything in the command string)
|
|
51
|
+
# @param env [Hash<String, String>] additional environment variables
|
|
52
|
+
# @param payload [Hash] the JSON payload (must include :hookEventName)
|
|
53
|
+
# @param timeout [Integer, nil] per-call timeout override
|
|
54
|
+
# @return [Hash] the parsed JSON response from stdout (empty hash if no output)
|
|
55
|
+
# @raise [ExecutionError] on spawn failure
|
|
56
|
+
# @raise [TimeoutError] if the command does not finish in time
|
|
57
|
+
def run(command:, payload:, args: [], env: {}, timeout: nil)
|
|
58
|
+
timeout ||= @default_timeout
|
|
59
|
+
env = default_env.merge(env)
|
|
60
|
+
|
|
61
|
+
stdout, _stderr, = invoke(command, args, env, payload, timeout)
|
|
62
|
+
parse_response(stdout)
|
|
63
|
+
rescue Timeout::Error => e
|
|
64
|
+
raise TimeoutError, "Hook command '#{command}' timed out after #{timeout}s: #{e.message}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def default_env
|
|
70
|
+
# Open3 replaces the entire env when env: is given, so inherit the
|
|
71
|
+
# parent's environment first and add our hook-specific markers.
|
|
72
|
+
ENV.to_h.merge(
|
|
73
|
+
'RUBYN_HOOK_EVENT' => '1',
|
|
74
|
+
'CLAUDE_PROJECT_DIR' => @project_root
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def invoke(command, args, env, payload, timeout)
|
|
79
|
+
Timeout.timeout(timeout) do
|
|
80
|
+
Open3.capture3(env, command, *args, chdir: @project_root, stdin_data: JSON.generate(payload))
|
|
81
|
+
end
|
|
82
|
+
rescue Errno::ENOENT => e
|
|
83
|
+
raise ExecutionError, "Hook command not found: #{command} (#{e.message})"
|
|
84
|
+
rescue SystemCallError => e
|
|
85
|
+
raise ExecutionError, "Failed to spawn hook command '#{command}': #{e.message}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_response(stdout)
|
|
89
|
+
return {} if stdout.nil? || stdout.strip.empty?
|
|
90
|
+
|
|
91
|
+
# Try whole-output JSON first (Claude Code's preferred shape).
|
|
92
|
+
begin
|
|
93
|
+
parsed = JSON.parse(stdout)
|
|
94
|
+
return parsed.is_a?(Hash) ? parsed : { 'output' => parsed }
|
|
95
|
+
rescue JSON::ParserError
|
|
96
|
+
# Fall through to line-delimited scanning.
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
stdout.each_line do |line|
|
|
100
|
+
stripped = line.strip
|
|
101
|
+
next if stripped.empty?
|
|
102
|
+
|
|
103
|
+
begin
|
|
104
|
+
parsed = JSON.parse(stripped)
|
|
105
|
+
return parsed.is_a?(Hash) ? parsed : { 'output' => parsed }
|
|
106
|
+
rescue JSON::ParserError
|
|
107
|
+
next
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Non-JSON output: treat as raw output for debugging hooks.
|
|
112
|
+
{ 'output' => stdout }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|