rubyn-code 0.5.1 → 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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -3
  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/commands/agents.rb +31 -0
  17. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  18. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  19. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  20. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  21. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  23. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  24. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  25. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  26. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  27. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  28. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  29. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  30. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  31. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  32. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  33. data/lib/rubyn_code/cli/first_run.rb +1 -1
  34. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  35. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  36. data/lib/rubyn_code/cli/renderer.rb +3 -2
  37. data/lib/rubyn_code/cli/repl.rb +37 -14
  38. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  39. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  40. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  41. data/lib/rubyn_code/cli/version_check.rb +10 -3
  42. data/lib/rubyn_code/config/defaults.rb +13 -1
  43. data/lib/rubyn_code/config/schema.json +4 -0
  44. data/lib/rubyn_code/config/settings.rb +17 -2
  45. data/lib/rubyn_code/context/manager.rb +29 -12
  46. data/lib/rubyn_code/debug.rb +11 -5
  47. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  48. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  49. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  50. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  51. data/lib/rubyn_code/hooks/response.rb +83 -0
  52. data/lib/rubyn_code/hooks/runner.rb +61 -3
  53. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  54. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  55. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  56. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  57. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  58. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  59. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  60. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  61. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  62. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  63. data/lib/rubyn_code/learning/porter.rb +129 -0
  64. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  65. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  66. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  67. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  68. data/lib/rubyn_code/llm/model_router.rb +2 -2
  69. data/lib/rubyn_code/mcp/client.rb +59 -0
  70. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  71. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  72. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  73. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  74. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  75. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  76. data/lib/rubyn_code/memory/search.rb +9 -5
  77. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  78. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  79. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  80. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  81. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  82. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  83. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  84. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  85. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  86. data/lib/rubyn_code/teams/manager.rb +83 -5
  87. data/lib/rubyn_code/teams/teammate.rb +5 -1
  88. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  89. data/lib/rubyn_code/tools/executor.rb +5 -3
  90. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  91. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  92. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  93. data/lib/rubyn_code/tools/web_search.rb +4 -1
  94. data/lib/rubyn_code/version.rb +1 -1
  95. data/lib/rubyn_code.rb +45 -2
  96. data/skills/rubyn_self_test.md +322 -14
  97. data/skills/self_test/chisel_smoke.rb +84 -0
  98. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  99. metadata +37 -1
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ # Session goals: a goal is a plain-language condition the user wants
5
+ # satisfied before the agent stops working. The Goal::Evaluator judges,
6
+ # via a lightweight LLM call, whether the condition has been met based on
7
+ # the recent conversation. Used by Hooks::GoalHook on the :stop event.
8
+ module Goal
9
+ # Judges whether a goal condition has been satisfied.
10
+ #
11
+ # The evaluator is deliberately conservative: it returns true only when
12
+ # the model is confident the goal is genuinely complete. Any error or
13
+ # ambiguous answer is treated as "not met" so the agent keeps working
14
+ # rather than stopping prematurely.
15
+ class Evaluator
16
+ SYSTEM_PROMPT = <<~PROMPT
17
+ You are a strict completion judge. Given a GOAL and a transcript of an
18
+ AI coding agent's recent work, decide whether the goal is genuinely and
19
+ fully satisfied. Be conservative: if there is any doubt, or the work is
20
+ only partially done, answer NO. Answer with exactly one word on the
21
+ first line: YES or NO. Optionally add a short reason on the next line.
22
+ PROMPT
23
+
24
+ # Number of trailing conversation messages to show the judge.
25
+ TRANSCRIPT_WINDOW = 12
26
+
27
+ # @param llm_client [LLM::Client]
28
+ def initialize(llm_client:)
29
+ @llm_client = llm_client
30
+ end
31
+
32
+ # @param condition [String] the goal condition
33
+ # @param conversation [Agent::Conversation, nil] recent work to judge
34
+ # @return [Boolean] true only when the goal is confidently complete
35
+ def call(condition:, conversation: nil)
36
+ response = @llm_client.chat(
37
+ messages: [{ role: 'user', content: prompt(condition, conversation) }],
38
+ system: SYSTEM_PROMPT
39
+ )
40
+ verdict_yes?(answer_text(response))
41
+ rescue StandardError => e
42
+ RubynCode::Debug.warn("Goal evaluation failed: #{e.message}")
43
+ false
44
+ end
45
+
46
+ private
47
+
48
+ def prompt(condition, conversation)
49
+ <<~TEXT
50
+ GOAL:
51
+ #{condition}
52
+
53
+ RECENT WORK:
54
+ #{transcript(conversation)}
55
+
56
+ Is the goal genuinely and fully satisfied? Answer YES or NO.
57
+ TEXT
58
+ end
59
+
60
+ def transcript(conversation)
61
+ return '(no work recorded yet)' unless conversation.respond_to?(:messages)
62
+
63
+ Array(conversation.messages).last(TRANSCRIPT_WINDOW).map do |msg|
64
+ "#{msg[:role]}: #{message_text(msg[:content])}"
65
+ end.join("\n").slice(0, 6000)
66
+ end
67
+
68
+ def message_text(content)
69
+ case content
70
+ when String then content
71
+ when Array
72
+ content.filter_map { |b| b.is_a?(Hash) ? (b[:text] || b['text']) : nil }.join(' ')
73
+ else
74
+ content.to_s
75
+ end
76
+ end
77
+
78
+ def answer_text(response)
79
+ if response.respond_to?(:content)
80
+ Array(response.content).filter_map { |b| b.respond_to?(:text) ? b.text : nil }.join("\n")
81
+ elsif response.is_a?(Hash)
82
+ Array(response[:content] || response['content'])
83
+ .filter_map { |b| b.is_a?(Hash) ? (b[:text] || b['text']) : nil }.join("\n")
84
+ else
85
+ response.to_s
86
+ end
87
+ end
88
+
89
+ def verdict_yes?(text)
90
+ first = text.to_s.strip.split("\n").first.to_s.strip.upcase
91
+ first.start_with?('YES')
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Hooks
5
+ # Maps between internal hook event names (snake_case symbols used by the
6
+ # in-process Hooks::Runner) and the Claude Code hook event names
7
+ # (CamelCase strings used by external hooks configured in settings.json).
8
+ #
9
+ # Internal hooks fire at these 7 sites:
10
+ # :pre_tool_use — agent/tool_processor.rb#execute_tool
11
+ # :post_tool_use — agent/tool_processor.rb#execute_tool
12
+ # :pre_llm_call — agent/llm_caller.rb
13
+ # :post_llm_call — agent/llm_caller.rb
14
+ # :stop — agent/loop.rb#stop_blocked?
15
+ # :session_start — ide/handlers/prompt_handler.rb (IDE)
16
+ # :user_prompt_submit — ide/handlers/prompt_handler.rb (IDE)
17
+ #
18
+ # External hooks (Claude Code parity) consume these 9 event names:
19
+ # PreToolUse, PostToolUse, UserPromptSubmit, SessionStart,
20
+ # SessionEnd, Stop, SubagentStop, PreCompact, Notification
21
+ module EventMap
22
+ # Internal symbol => external string
23
+ TO_EXTERNAL = {
24
+ pre_tool_use: 'PreToolUse',
25
+ post_tool_use: 'PostToolUse',
26
+ pre_llm_call: 'PreCompact',
27
+ post_llm_call: 'Notification',
28
+ on_session_end: 'SessionEnd',
29
+ session_start: 'SessionStart',
30
+ user_prompt_submit: 'UserPromptSubmit',
31
+ stop: 'Stop',
32
+ on_subagent_stop: 'SubagentStop'
33
+ }.freeze
34
+
35
+ # External string => internal symbol
36
+ TO_INTERNAL = TO_EXTERNAL.invert.freeze
37
+
38
+ # Every external event name the dispatcher knows about.
39
+ EXTERNAL_EVENTS = TO_EXTERNAL.values.freeze
40
+
41
+ module_function
42
+
43
+ # @param internal [Symbol]
44
+ # @return [String, nil]
45
+ def external(internal)
46
+ TO_EXTERNAL[internal.to_sym]
47
+ end
48
+
49
+ # @param external [String, Symbol]
50
+ # @return [Symbol, nil]
51
+ def internal(external)
52
+ TO_INTERNAL[external.to_s]
53
+ end
54
+ end
55
+ end
56
+ end
@@ -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)