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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +182 -11
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +89 -3
  5. data/lib/rubyn_code/agent/llm_caller.rb +2 -2
  6. data/lib/rubyn_code/agent/loop.rb +49 -9
  7. data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
  8. data/lib/rubyn_code/agent/tool_processor.rb +3 -1
  9. data/lib/rubyn_code/auth/oauth.rb +1 -1
  10. data/lib/rubyn_code/auth/token_store.rb +49 -4
  11. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  12. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  13. data/lib/rubyn_code/chisel/debt.rb +65 -0
  14. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  15. data/lib/rubyn_code/chisel.rb +127 -0
  16. data/lib/rubyn_code/cli/app.rb +2 -2
  17. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  18. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  19. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  20. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  21. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  22. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  23. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  24. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  25. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  26. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  27. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  28. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  29. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  30. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  31. data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
  32. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  33. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  34. data/lib/rubyn_code/cli/first_run.rb +1 -1
  35. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  36. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  37. data/lib/rubyn_code/cli/renderer.rb +3 -2
  38. data/lib/rubyn_code/cli/repl.rb +37 -14
  39. data/lib/rubyn_code/cli/repl_commands.rb +77 -2
  40. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  41. data/lib/rubyn_code/cli/setup.rb +13 -0
  42. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  43. data/lib/rubyn_code/cli/version_check.rb +10 -3
  44. data/lib/rubyn_code/config/defaults.rb +13 -1
  45. data/lib/rubyn_code/config/schema.json +4 -0
  46. data/lib/rubyn_code/config/settings.rb +17 -2
  47. data/lib/rubyn_code/context/manager.rb +29 -12
  48. data/lib/rubyn_code/debug.rb +11 -5
  49. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  50. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  51. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  52. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  53. data/lib/rubyn_code/hooks/response.rb +83 -0
  54. data/lib/rubyn_code/hooks/runner.rb +61 -3
  55. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  56. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  57. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
  58. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
  59. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
  60. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
  61. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  62. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +143 -0
  63. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  64. data/lib/rubyn_code/ide/handlers.rb +17 -2
  65. data/lib/rubyn_code/ide/protocol.rb +15 -0
  66. data/lib/rubyn_code/ide/server.rb +39 -1
  67. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  68. data/lib/rubyn_code/learning/porter.rb +129 -0
  69. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  70. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  71. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  72. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  73. data/lib/rubyn_code/llm/model_router.rb +2 -2
  74. data/lib/rubyn_code/mcp/client.rb +59 -0
  75. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  76. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  77. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  78. data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
  79. data/lib/rubyn_code/megaplan/interview_session.rb +250 -0
  80. data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
  81. data/lib/rubyn_code/memory/search.rb +9 -5
  82. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  83. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  84. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  85. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  86. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  87. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  88. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  89. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  90. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  91. data/lib/rubyn_code/teams/manager.rb +83 -5
  92. data/lib/rubyn_code/teams/teammate.rb +5 -1
  93. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  94. data/lib/rubyn_code/tools/executor.rb +5 -3
  95. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  96. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  97. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  98. data/lib/rubyn_code/tools/web_search.rb +4 -1
  99. data/lib/rubyn_code/version.rb +1 -1
  100. data/lib/rubyn_code.rb +53 -2
  101. data/skills/megaplan/megaplan.md +156 -0
  102. data/skills/rubyn_self_test.md +322 -14
  103. data/skills/self_test/chisel_smoke.rb +84 -0
  104. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  105. 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
- def initialize(registry: Registry.new)
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
- return if hooks.empty?
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