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
|
@@ -33,7 +33,11 @@ module RubynCode
|
|
|
33
33
|
@tool_executor = Tools::Executor.new(project_root: @project_root)
|
|
34
34
|
@context_manager = Context::Manager.new(llm_client: @llm_client)
|
|
35
35
|
@hook_registry = Hooks::Registry.new
|
|
36
|
-
@
|
|
36
|
+
@external_hook_dispatcher = Hooks::ExternalDispatcher.new(project_root: @project_root)
|
|
37
|
+
@hook_runner = Hooks::Runner.new(
|
|
38
|
+
registry: @hook_registry,
|
|
39
|
+
external_dispatcher: @external_hook_dispatcher
|
|
40
|
+
)
|
|
37
41
|
@stall_detector = Agent::LoopDetector.new
|
|
38
42
|
end
|
|
39
43
|
|
|
@@ -45,6 +49,8 @@ module RubynCode
|
|
|
45
49
|
@skill_matcher = build_skill_matcher
|
|
46
50
|
@web_skill_autoload = build_web_skill_autoload
|
|
47
51
|
@session_persistence = Memory::SessionPersistence.new(@db)
|
|
52
|
+
@mention_expander = MentionExpander.new(project_root: @project_root)
|
|
53
|
+
@checkpoint_manager = Checkpoint::Manager.new(project_root: @project_root)
|
|
48
54
|
end
|
|
49
55
|
|
|
50
56
|
def build_skill_matcher
|
|
@@ -127,6 +133,8 @@ module RubynCode
|
|
|
127
133
|
def setup_hooks!
|
|
128
134
|
Hooks::BuiltIn.register_all!(@hook_registry)
|
|
129
135
|
Hooks::UserHooks.load!(@hook_registry, project_root: @project_root)
|
|
136
|
+
# Snapshot files before they are mutated so /rewind can restore them.
|
|
137
|
+
@hook_registry.on(:pre_tool_use, Checkpoint::Hook.new(manager: @checkpoint_manager), priority: 5)
|
|
130
138
|
end
|
|
131
139
|
|
|
132
140
|
def setup_agent_loop!
|
data/lib/rubyn_code/cli/setup.rb
CHANGED
|
@@ -95,6 +95,19 @@ module RubynCode
|
|
|
95
95
|
#
|
|
96
96
|
# To regenerate: rubyn-code --setup
|
|
97
97
|
# To remove: rm #{path}
|
|
98
|
+
if [ ! -x "#{pinned_ruby}" ] || [ ! -f "#{gem_wrapper}" ]; then
|
|
99
|
+
echo "rubyn-code: launcher target missing" >&2
|
|
100
|
+
[ ! -x "#{pinned_ruby}" ] && echo " pinned Ruby: #{pinned_ruby}" >&2
|
|
101
|
+
[ ! -f "#{gem_wrapper}" ] && echo " gem wrapper: #{gem_wrapper}" >&2
|
|
102
|
+
echo >&2
|
|
103
|
+
echo "The Ruby that 'rubyn-code --setup' was pinned against (or the gem" >&2
|
|
104
|
+
echo "itself) was removed. To recover under your current Ruby:" >&2
|
|
105
|
+
echo >&2
|
|
106
|
+
echo " rm '$0'" >&2
|
|
107
|
+
echo " gem install rubyn-code" >&2
|
|
108
|
+
echo " rubyn-code --setup" >&2
|
|
109
|
+
exit 127
|
|
110
|
+
fi
|
|
98
111
|
exec "#{pinned_ruby}" "#{gem_wrapper}" "$@"
|
|
99
112
|
BASH
|
|
100
113
|
end
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'pastel'
|
|
4
|
-
require 'rouge'
|
|
5
4
|
|
|
6
5
|
module RubynCode
|
|
7
6
|
module CLI
|
|
@@ -11,7 +10,6 @@ module RubynCode
|
|
|
11
10
|
class StreamFormatter
|
|
12
11
|
def initialize(_renderer = nil)
|
|
13
12
|
@pastel = Pastel.new
|
|
14
|
-
@rouge_formatter = Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
|
|
15
13
|
@buffer = +''
|
|
16
14
|
@in_code_block = false
|
|
17
15
|
@code_lang = nil
|
|
@@ -98,6 +96,9 @@ module RubynCode
|
|
|
98
96
|
end
|
|
99
97
|
|
|
100
98
|
def output_highlighted_code
|
|
99
|
+
# Lazy so REPL boot never pays for rouge until a code block is rendered.
|
|
100
|
+
require 'rouge'
|
|
101
|
+
@rouge_formatter ||= Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
|
|
101
102
|
lexer = Rouge::Lexer.find(@code_lang || 'ruby') || Rouge::Lexers::PlainText.new
|
|
102
103
|
highlighted = @rouge_formatter.format(lexer.lex(@code_buffer))
|
|
103
104
|
border = @pastel.dim(' │ ')
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'faraday'
|
|
4
3
|
require 'json'
|
|
5
4
|
|
|
6
5
|
module RubynCode
|
|
@@ -16,6 +15,7 @@ module RubynCode
|
|
|
16
15
|
def initialize(renderer:)
|
|
17
16
|
@renderer = renderer
|
|
18
17
|
@thread = nil
|
|
18
|
+
@notified = false
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Kicks off a background check. Call `notify` later to display results.
|
|
@@ -26,11 +26,17 @@ module RubynCode
|
|
|
26
26
|
@thread.abort_on_exception = false
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
#
|
|
30
|
-
|
|
29
|
+
# Prints a message if outdated. Non-blocking by default: if the check
|
|
30
|
+
# hasn't finished yet, returns immediately so it can be retried before
|
|
31
|
+
# the next prompt. Notifies at most once.
|
|
32
|
+
def notify(timeout: 0)
|
|
33
|
+
return if @notified
|
|
31
34
|
return unless @thread
|
|
32
35
|
|
|
33
36
|
@thread.join(timeout)
|
|
37
|
+
return if @thread.alive?
|
|
38
|
+
|
|
39
|
+
@notified = true
|
|
34
40
|
return unless @result
|
|
35
41
|
|
|
36
42
|
return unless newer?(@result, RubynCode::VERSION)
|
|
@@ -60,6 +66,7 @@ module RubynCode
|
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
def fetch_latest_version
|
|
69
|
+
require 'faraday'
|
|
63
70
|
conn = Faraday.new do |f|
|
|
64
71
|
f.options.timeout = 5
|
|
65
72
|
f.options.open_timeout = 3
|
|
@@ -11,9 +11,14 @@ module RubynCode
|
|
|
11
11
|
MEMORIES_DIR = File.join(HOME_DIR, 'memories')
|
|
12
12
|
|
|
13
13
|
DEFAULT_PROVIDER = 'anthropic'
|
|
14
|
-
DEFAULT_MODEL = 'claude-opus-4-
|
|
14
|
+
DEFAULT_MODEL = 'claude-opus-4-8'
|
|
15
15
|
MODEL_MODE = 'auto' # 'auto' or 'manual'
|
|
16
16
|
MAX_ITERATIONS = 200
|
|
17
|
+
# Hard ceiling when a Stop hook (e.g. an active /goal) keeps the agent
|
|
18
|
+
# working past MAX_ITERATIONS. A goal can legitimately need more tool
|
|
19
|
+
# turns than a single request; the GoalHook's own max-attempts valve is
|
|
20
|
+
# the real terminator — this only guards against a runaway loop.
|
|
21
|
+
GOAL_MAX_ITERATIONS = 2_000
|
|
17
22
|
MAX_SUB_AGENT_ITERATIONS = 200
|
|
18
23
|
MAX_EXPLORE_AGENT_ITERATIONS = 200
|
|
19
24
|
|
|
@@ -33,9 +38,16 @@ module RubynCode
|
|
|
33
38
|
|
|
34
39
|
SKILLS_AUTOLOAD = true
|
|
35
40
|
|
|
41
|
+
# Chisel: opt-in "write the minimum that works" enforcement. Off by
|
|
42
|
+
# default — only changes agent behavior once the user turns it on.
|
|
43
|
+
CHISEL_MODE = 'off'
|
|
44
|
+
|
|
36
45
|
SESSION_BUDGET_USD = 5.00
|
|
37
46
|
DAILY_BUDGET_USD = 10.00
|
|
38
47
|
|
|
48
|
+
# Claude Code's credentials file (Linux/other — no system keychain)
|
|
49
|
+
CLAUDE_CREDENTIALS_FILE = File.expand_path('~/.claude/.credentials.json')
|
|
50
|
+
|
|
39
51
|
OAUTH_CLIENT_ID = 'rubyn-code'
|
|
40
52
|
OAUTH_REDIRECT_URI = 'http://localhost:19275/callback'
|
|
41
53
|
OAUTH_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
|
+
require 'tmpdir'
|
|
4
5
|
require 'fileutils'
|
|
5
6
|
require_relative 'defaults'
|
|
6
7
|
|
|
@@ -17,6 +18,7 @@ module RubynCode
|
|
|
17
18
|
oauth_client_id oauth_redirect_uri oauth_authorize_url
|
|
18
19
|
oauth_token_url oauth_scopes
|
|
19
20
|
skills_autoload
|
|
21
|
+
chisel_mode
|
|
20
22
|
].freeze
|
|
21
23
|
|
|
22
24
|
DEFAULT_MAP = {
|
|
@@ -37,12 +39,19 @@ module RubynCode
|
|
|
37
39
|
oauth_authorize_url: Defaults::OAUTH_AUTHORIZE_URL,
|
|
38
40
|
oauth_token_url: Defaults::OAUTH_TOKEN_URL,
|
|
39
41
|
oauth_scopes: Defaults::OAUTH_SCOPES,
|
|
40
|
-
skills_autoload: Defaults::SKILLS_AUTOLOAD
|
|
42
|
+
skills_autoload: Defaults::SKILLS_AUTOLOAD,
|
|
43
|
+
chisel_mode: Defaults::CHISEL_MODE
|
|
41
44
|
}.freeze
|
|
42
45
|
|
|
43
46
|
attr_reader :config_path, :data
|
|
44
47
|
|
|
45
48
|
def initialize(config_path: Defaults::CONFIG_FILE)
|
|
49
|
+
# When tests run, isolate Settings from the developer's personal
|
|
50
|
+
# ~/.rubyn-code/config.yml so a stray `provider: minimax` can't
|
|
51
|
+
# shadow the test expectations. The test config lives in
|
|
52
|
+
# tmpdir, is process-pid-scoped, and is harmless if it leaks.
|
|
53
|
+
config_path = self.class.test_config_path if config_path == Defaults::CONFIG_FILE && ENV['RUBYN_TESTING']
|
|
54
|
+
|
|
46
55
|
@config_path = config_path
|
|
47
56
|
@data = {}
|
|
48
57
|
ensure_home_directory!
|
|
@@ -51,6 +60,12 @@ module RubynCode
|
|
|
51
60
|
backfill_provider_models!
|
|
52
61
|
end
|
|
53
62
|
|
|
63
|
+
# @return [String] a per-pid path under tmpdir used when
|
|
64
|
+
# RUBYN_TESTING is set
|
|
65
|
+
def self.test_config_path
|
|
66
|
+
@test_config_path ||= File.join(Dir.tmpdir, "rubyn-test-config-#{Process.pid}.yml")
|
|
67
|
+
end
|
|
68
|
+
|
|
54
69
|
# Define accessor methods for each configurable key
|
|
55
70
|
CONFIGURABLE_KEYS.each do |key|
|
|
56
71
|
define_method(key) do
|
|
@@ -141,7 +156,7 @@ module RubynCode
|
|
|
141
156
|
DEFAULT_PROVIDER_MODELS = {
|
|
142
157
|
'anthropic' => {
|
|
143
158
|
'env_key' => 'ANTHROPIC_API_KEY',
|
|
144
|
-
'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-4-6', 'top' => 'claude-opus-4-
|
|
159
|
+
'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-4-6', 'top' => 'claude-opus-4-8' }
|
|
145
160
|
},
|
|
146
161
|
'openai' => {
|
|
147
162
|
'env_key' => 'OPENAI_API_KEY',
|
|
@@ -39,24 +39,30 @@ module RubynCode
|
|
|
39
39
|
@total_output_tokens += usage.output_tokens.to_i
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
# Rough estimate of token count
|
|
43
|
-
#
|
|
42
|
+
# Rough estimate of token count based on JSON-serialized character
|
|
43
|
+
# length (~4 chars per token). Accepts either a raw messages array or
|
|
44
|
+
# an object exposing #estimated_json_chars (e.g. Agent::Conversation),
|
|
45
|
+
# which avoids re-serializing the whole history on every call.
|
|
44
46
|
#
|
|
45
|
-
# @param
|
|
47
|
+
# @param source [Array<Hash>, #estimated_json_chars] conversation messages
|
|
46
48
|
# @return [Integer] estimated token count
|
|
47
|
-
def estimated_tokens(
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
def estimated_tokens(source)
|
|
50
|
+
chars = if source.respond_to?(:estimated_json_chars)
|
|
51
|
+
source.estimated_json_chars
|
|
52
|
+
else
|
|
53
|
+
JSON.generate(source).length
|
|
54
|
+
end
|
|
55
|
+
(chars.to_f / CHARS_PER_TOKEN).ceil
|
|
50
56
|
rescue JSON::GeneratorError
|
|
51
57
|
0
|
|
52
58
|
end
|
|
53
59
|
|
|
54
60
|
# Returns true if the estimated token count exceeds the threshold.
|
|
55
61
|
#
|
|
56
|
-
# @param
|
|
62
|
+
# @param source [Array<Hash>, #estimated_json_chars] conversation messages
|
|
57
63
|
# @return [Boolean]
|
|
58
|
-
def needs_compaction?(
|
|
59
|
-
estimated_tokens(
|
|
64
|
+
def needs_compaction?(source)
|
|
65
|
+
estimated_tokens(source) > @threshold
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
# Runs micro-compaction every turn and auto-compaction when the context
|
|
@@ -78,14 +84,17 @@ module RubynCode
|
|
|
78
84
|
return if @last_compaction_turn == @current_turn
|
|
79
85
|
|
|
80
86
|
messages = conversation.messages
|
|
87
|
+
estimate_source = conversation.respond_to?(:estimated_json_chars) ? conversation : messages
|
|
81
88
|
|
|
82
89
|
# Step 1: Zero-cost micro-compact — but only when we're approaching
|
|
83
90
|
# the compaction threshold. Running it every turn mutates old messages,
|
|
84
91
|
# which invalidates the prompt cache prefix and wastes tokens.
|
|
85
|
-
est = estimated_tokens(
|
|
86
|
-
|
|
92
|
+
est = estimated_tokens(estimate_source)
|
|
93
|
+
if est > (@threshold * micro_compact_ratio) && MicroCompact.call(messages).to_i.positive?
|
|
94
|
+
refresh_conversation_estimate(conversation)
|
|
95
|
+
end
|
|
87
96
|
|
|
88
|
-
return unless needs_compaction?(
|
|
97
|
+
return unless needs_compaction?(estimate_source)
|
|
89
98
|
|
|
90
99
|
# Step 2: Try context collapse (snip old messages, no LLM call)
|
|
91
100
|
collapsed = ContextCollapse.call(messages, threshold: @threshold)
|
|
@@ -138,6 +147,14 @@ module RubynCode
|
|
|
138
147
|
elsif conversation.respond_to?(:messages=)
|
|
139
148
|
conversation.messages = new_messages
|
|
140
149
|
end
|
|
150
|
+
refresh_conversation_estimate(conversation)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# MicroCompact and the compaction strategies mutate or replace messages
|
|
154
|
+
# outside the conversation's own append path, so its incremental token
|
|
155
|
+
# bookkeeping must be invalidated afterwards.
|
|
156
|
+
def refresh_conversation_estimate(conversation)
|
|
157
|
+
conversation.refresh_derived_state! if conversation.respond_to?(:refresh_derived_state!)
|
|
141
158
|
end
|
|
142
159
|
end
|
|
143
160
|
end
|
data/lib/rubyn_code/debug.rb
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'pastel'
|
|
4
|
-
|
|
5
3
|
module RubynCode
|
|
6
4
|
module Debug
|
|
7
|
-
PASTEL = Pastel.new
|
|
8
|
-
|
|
9
5
|
@enabled = false
|
|
10
6
|
@output = $stderr
|
|
11
7
|
|
|
@@ -32,7 +28,7 @@ module RubynCode
|
|
|
32
28
|
return unless enabled?
|
|
33
29
|
|
|
34
30
|
timestamp = Time.now.strftime('%H:%M:%S.%L')
|
|
35
|
-
prefix = "#{
|
|
31
|
+
prefix = "#{pastel.dim("[#{timestamp}]")} #{pastel.send(color, "[#{tag}]")}"
|
|
36
32
|
@output.puts "#{prefix} #{message}"
|
|
37
33
|
end
|
|
38
34
|
|
|
@@ -69,6 +65,16 @@ module RubynCode
|
|
|
69
65
|
def error(message)
|
|
70
66
|
log('error', message, color: :red)
|
|
71
67
|
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Lazy so boot never pays for pastel when debug output is off.
|
|
72
|
+
def pastel
|
|
73
|
+
@pastel ||= begin
|
|
74
|
+
require 'pastel'
|
|
75
|
+
Pastel.new
|
|
76
|
+
end
|
|
77
|
+
end
|
|
72
78
|
end
|
|
73
79
|
end
|
|
74
80
|
end
|
|
@@ -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
|