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.
- checksums.yaml +4 -4
- data/README.md +120 -3
- 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/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 +1 -1
- 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 +76 -2
- data/lib/rubyn_code/cli/repl_setup.rb +9 -1
- 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 +13 -13
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -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 +3 -3
- data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
- data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
- 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 +45 -2
- 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 +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
|
-
|
|
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)
|