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
@@ -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
- @hook_runner = Hooks::Runner.new(registry: @hook_registry)
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!
@@ -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
- # Waits briefly for the check to finish and prints a message if outdated.
30
- def notify(timeout: 2)
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-6'
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'
@@ -43,6 +43,10 @@
43
43
  "type": "number",
44
44
  "minimum": 0.5,
45
45
  "maximum": 500
46
+ },
47
+ "chisel_mode": {
48
+ "type": "string",
49
+ "enum": ["off", "lite", "full", "ultra"]
46
50
  }
47
51
  },
48
52
  "additionalProperties": true
@@ -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-6' }
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 for a set of messages based on their
43
- # JSON-serialized character length (~4 chars per token).
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 messages [Array<Hash>] conversation messages
47
+ # @param source [Array<Hash>, #estimated_json_chars] conversation messages
46
48
  # @return [Integer] estimated token count
47
- def estimated_tokens(messages)
48
- json = JSON.generate(messages)
49
- (json.length.to_f / CHARS_PER_TOKEN).ceil
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 messages [Array<Hash>] conversation messages
62
+ # @param source [Array<Hash>, #estimated_json_chars] conversation messages
57
63
  # @return [Boolean]
58
- def needs_compaction?(messages)
59
- estimated_tokens(messages) > @threshold
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(messages)
86
- MicroCompact.call(messages) if est > (@threshold * micro_compact_ratio)
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?(messages)
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
@@ -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 = "#{PASTEL.dim("[#{timestamp}]")} #{PASTEL.send(color, "[#{tag}]")}"
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