rubyn-code 0.5.1 → 0.8.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +137 -4
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +100 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  6. data/lib/rubyn_code/agent/llm_caller.rb +7 -3
  7. data/lib/rubyn_code/agent/loop.rb +106 -12
  8. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
  10. data/lib/rubyn_code/agent/tool_processor.rb +13 -1
  11. data/lib/rubyn_code/auth/oauth.rb +1 -1
  12. data/lib/rubyn_code/auth/token_store.rb +49 -4
  13. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  14. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  15. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  16. data/lib/rubyn_code/chisel/debt.rb +65 -0
  17. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  18. data/lib/rubyn_code/chisel.rb +127 -0
  19. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  20. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  21. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  23. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  24. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  25. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  26. data/lib/rubyn_code/cli/commands/context.rb +30 -1
  27. data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
  28. data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
  29. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  30. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  31. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  32. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  33. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  34. data/lib/rubyn_code/cli/commands/mcp.rb +49 -12
  35. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  36. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  37. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  38. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  39. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  40. data/lib/rubyn_code/cli/first_run.rb +1 -1
  41. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  42. data/lib/rubyn_code/cli/mention_expander.rb +111 -0
  43. data/lib/rubyn_code/cli/renderer.rb +3 -2
  44. data/lib/rubyn_code/cli/repl.rb +68 -15
  45. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  46. data/lib/rubyn_code/cli/repl_setup.rb +17 -7
  47. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  48. data/lib/rubyn_code/cli/version_check.rb +10 -3
  49. data/lib/rubyn_code/config/defaults.rb +14 -1
  50. data/lib/rubyn_code/config/schema.json +9 -0
  51. data/lib/rubyn_code/config/settings.rb +19 -2
  52. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  53. data/lib/rubyn_code/context/manager.rb +29 -12
  54. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  55. data/lib/rubyn_code/debug.rb +11 -5
  56. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  57. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  58. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  59. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  60. data/lib/rubyn_code/hooks/response.rb +83 -0
  61. data/lib/rubyn_code/hooks/runner.rb +61 -3
  62. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  63. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  64. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  65. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  66. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  67. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  69. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  70. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  71. data/lib/rubyn_code/index/codebase_index.rb +103 -4
  72. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  73. data/lib/rubyn_code/learning/injector.rb +1 -2
  74. data/lib/rubyn_code/learning/porter.rb +129 -0
  75. data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
  76. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  77. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  78. data/lib/rubyn_code/llm/adapters/openai.rb +2 -2
  79. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  80. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  81. data/lib/rubyn_code/llm/client.rb +16 -3
  82. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  83. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  84. data/lib/rubyn_code/llm/model_router.rb +4 -4
  85. data/lib/rubyn_code/mcp/client.rb +59 -0
  86. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  87. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  88. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  90. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  91. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  92. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  93. data/lib/rubyn_code/memory/search.rb +9 -5
  94. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  95. data/lib/rubyn_code/observability/cost_calculator.rb +8 -3
  96. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  97. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  98. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  99. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  100. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  101. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  102. data/lib/rubyn_code/tasks/models.rb +0 -16
  103. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  104. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  105. data/lib/rubyn_code/teams/manager.rb +83 -5
  106. data/lib/rubyn_code/teams/teammate.rb +3 -14
  107. data/lib/rubyn_code/tools/RUBYN.md +2 -2
  108. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  109. data/lib/rubyn_code/tools/bash.rb +3 -3
  110. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  111. data/lib/rubyn_code/tools/executor.rb +9 -4
  112. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  113. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  114. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  115. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  116. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  117. data/lib/rubyn_code/tools/web_search.rb +4 -1
  118. data/lib/rubyn_code/version.rb +1 -1
  119. data/lib/rubyn_code.rb +57 -9
  120. data/skills/rubyn_self_test.md +462 -14
  121. data/skills/self_test/chisel_smoke.rb +84 -0
  122. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  123. metadata +45 -6
  124. data/lib/rubyn_code/context/context_budget.rb +0 -183
  125. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  126. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  127. data/lib/rubyn_code/llm/streaming.rb +0 -10
  128. data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
@@ -9,7 +9,7 @@ require_relative 'llm_caller'
9
9
 
10
10
  module RubynCode
11
11
  module Agent
12
- class Loop
12
+ class Loop # rubocop:disable Metrics/ClassLength -- core agent loop: LLM calls, tool dispatch, recovery, hooks
13
13
  include SystemPromptBuilder
14
14
  include ResponseParser
15
15
  include ToolProcessor
@@ -18,6 +18,7 @@ module RubynCode
18
18
  include LlmCaller
19
19
 
20
20
  MAX_ITERATIONS = Config::Defaults::MAX_ITERATIONS
21
+ GOAL_MAX_ITERATIONS = Config::Defaults::GOAL_MAX_ITERATIONS
21
22
 
22
23
  # @param opts [Hash] keyword arguments for loop configuration
23
24
  # @option opts [LLM::Client] :llm_client
@@ -39,6 +40,7 @@ module RubynCode
39
40
  assign_dependencies(opts)
40
41
  assign_callbacks(opts)
41
42
  @plan_mode = false
43
+ @static_prompt_sections = nil
42
44
  end
43
45
 
44
46
  # @return [Boolean]
@@ -51,8 +53,11 @@ module RubynCode
51
53
  # response is produced or the iteration limit is reached.
52
54
  #
53
55
  # @param user_input [String]
56
+ # @param blocks [Array<Hash>, nil] extra content blocks to attach to the
57
+ # user message (e.g. image blocks from @-mentions). When non-empty the
58
+ # user message is stored as a mixed content array instead of a string.
54
59
  # @return [String] the final assistant text response
55
- def send_message(user_input)
60
+ def send_message(user_input, blocks: nil)
56
61
  initialize_session!
57
62
  check_user_feedback(user_input)
58
63
  drain_background_notifications
@@ -60,20 +65,67 @@ module RubynCode
60
65
  @decision_compactor&.detect_topic_switch(user_input)
61
66
  @skill_ttl&.tick!
62
67
  autoload_triggered_skills(user_input)
63
- @conversation.add_user_message(user_input)
68
+ append_user_message(user_input, blocks)
69
+ reset_system_prompt_cache!
64
70
  reset_iteration_state
65
71
 
66
- MAX_ITERATIONS.times do |iteration|
72
+ iteration = 0
73
+ loop do
67
74
  result = run_iteration(iteration)
68
75
  return result if result
76
+
77
+ iteration += 1
78
+ break unless keep_iterating?(iteration)
69
79
  end
70
80
 
71
- RubynCode::Debug.warn("Hit MAX_ITERATIONS (#{MAX_ITERATIONS})")
72
- max_iterations_warning
81
+ RubynCode::Debug.warn("Hit iteration limit (#{iteration})")
82
+ max_iterations_warning(iteration)
83
+ end
84
+
85
+ # Append the user turn to the conversation. If image blocks were passed
86
+ # via `blocks:`, the message is stored as a mixed content array with a
87
+ # leading text block and the image blocks appended in order.
88
+ def append_user_message(user_input, blocks)
89
+ if blocks.is_a?(Array) && !blocks.empty?
90
+ content = [{ type: 'text', text: user_input.to_s }, *blocks]
91
+ @conversation.add_user_message(content)
92
+ else
93
+ @conversation.add_user_message(user_input)
94
+ end
95
+ end
96
+
97
+ # @return [Tools::TodoStore] shared checklist store, exposed for the REPL renderer
98
+ attr_reader :todo_store
99
+
100
+ # Apply a one-shot model override for the duration of the next LLM call.
101
+ # Used by `Context#with_optional_model` for custom-command frontmatter.
102
+ def model_override=(model_name)
103
+ stripped = model_name.to_s.strip
104
+ @model_override = stripped.empty? ? nil : stripped
105
+ end
106
+
107
+ # Restrict the available tool set for the next LLM call only.
108
+ # @param allowed [Array<String>, nil] tool names, or nil to clear the override.
109
+ def allowed_tools_override=(allowed)
110
+ @allowed_tools_override = allowed.is_a?(Array) && !allowed.empty? ? allowed : nil
73
111
  end
74
112
 
75
113
  private
76
114
 
115
+ # Overrides the region above. The internal helpers below are private.
116
+
117
+ # Decide whether the loop should run another iteration after `iteration`
118
+ # turns. Normally capped at MAX_ITERATIONS, but while a Stop hook (e.g. an
119
+ # active /goal) is keeping the agent alive we extend up to a hard ceiling
120
+ # — a goal can need more tool turns than a single request. The GoalHook's
121
+ # own max-attempts valve terminates an unsatisfiable goal; the ceiling is
122
+ # only a runaway guard.
123
+ def keep_iterating?(iteration)
124
+ return true if iteration < MAX_ITERATIONS
125
+
126
+ @stop_block_active && iteration < GOAL_MAX_ITERATIONS
127
+ end
128
+
77
129
  def assign_dependencies(opts)
78
130
  assign_required_deps(opts)
79
131
  assign_optional_deps(opts)
@@ -100,6 +152,8 @@ module RubynCode
100
152
  @tool_wrapper = opts[:tool_wrapper]
101
153
  @decision_compactor = build_decision_compactor
102
154
  @skill_ttl = Skills::TtlManager.new
155
+ @todo_store = opts.fetch(:todo_store, Tools::TodoStore.new)
156
+ @tool_executor.todo_store = @todo_store
103
157
  @session_initialized = false
104
158
  end
105
159
 
@@ -114,6 +168,7 @@ module RubynCode
114
168
  def initialize_session!
115
169
  return if @session_initialized || !@project_root
116
170
 
171
+ @todo_store&.clear
117
172
  @session_initialized = true
118
173
  build_project_profile!
119
174
  build_codebase_index!
@@ -148,13 +203,18 @@ module RubynCode
148
203
  @max_tokens_override = nil
149
204
  @output_recovery_count = 0
150
205
  @task_budget_remaining = nil
206
+ @stop_block_active = false # true while a Stop hook keeps us going
207
+ @allowed_tools_override = nil # set by Context#with_allowed_tools
208
+ @model_override = nil # set by Context#with_optional_model
151
209
  end
152
210
 
153
211
  def run_iteration(iteration)
154
212
  log_iteration(iteration)
155
213
  @context_manager.advance_turn!
156
214
  compact_if_needed # ensure context is under threshold before LLM call
157
- response = call_llm
215
+ response = call_llm
216
+ return handle_refusal(response) if extract_stop_reason(response) == 'refusal'
217
+
158
218
  tool_calls = extract_tool_calls(response)
159
219
  log_response(response, tool_calls)
160
220
 
@@ -163,6 +223,20 @@ module RubynCode
163
223
  handle_tool_response(response, tool_calls, iteration)
164
224
  end
165
225
 
226
+ # Claude's safety classifiers declined the request outright (HTTP 200,
227
+ # stop_reason: "refusal"). Surface a clear message instead of falling
228
+ # through to text/empty-response handling, which would misread the
229
+ # empty or partial content as "waiting on background jobs".
230
+ def handle_refusal(response)
231
+ details = extract_stop_details(response)
232
+ category = details.is_a?(Hash) ? (details['category'] || details[:category]) : nil
233
+ RubynCode::Debug.llm("Refusal: category=#{category || 'unknown'}")
234
+
235
+ message = "Claude's safety system declined this request (category: #{category || 'unknown'})."
236
+ @conversation.add_assistant_message([{ type: 'text', text: message }])
237
+ message
238
+ end
239
+
166
240
  def log_iteration(iteration)
167
241
  RubynCode::Debug.loop_tick(
168
242
  "iteration=#{iteration} messages=#{@conversation.length} " \
@@ -199,6 +273,13 @@ module RubynCode
199
273
 
200
274
  @conversation.add_assistant_message(response_content(response))
201
275
 
276
+ # Stop hook: a hook may block stopping (e.g. an active /goal). When
277
+ # blocked, the reason is injected as user feedback and the loop keeps
278
+ # iterating instead of returning the final text. While blocked, the
279
+ # loop is allowed to run past MAX_ITERATIONS (see #keep_iterating?).
280
+ @stop_block_active = stop_blocked?(text)
281
+ return nil if @stop_block_active
282
+
202
283
  # Decision-based compaction (topic switch, milestone)
203
284
  @decision_compactor&.check!(@conversation)
204
285
 
@@ -208,6 +289,19 @@ module RubynCode
208
289
  text
209
290
  end
210
291
 
292
+ # Fires the :stop hook. If a hook blocks (returns { block: true }), the
293
+ # reason is appended as a user message so the next iteration acts on it.
294
+ #
295
+ # @return [Boolean] true if stopping was blocked (keep iterating)
296
+ def stop_blocked?(text)
297
+ decision = @hook_runner.fire(:stop, conversation: @conversation, response_text: text)
298
+ return false unless decision.is_a?(Hash) && decision[:block]
299
+
300
+ RubynCode::Debug.agent('Stop blocked by hook — continuing')
301
+ @conversation.add_user_message(decision[:reason])
302
+ true
303
+ end
304
+
211
305
  # Empty LLM response (0 content blocks). Common after dispatching
212
306
  # background_run — the LLM has nothing to say until results arrive.
213
307
  # Wait briefly for jobs, then either continue or accept the empty response.
@@ -243,15 +337,15 @@ module RubynCode
243
337
  # after text responses — mirrors Claude Code's "pause for compaction"
244
338
  # behavior that keeps context manageable in long sessions.
245
339
  def compact_if_needed
246
- return unless @context_manager.needs_compaction?(@conversation.messages)
340
+ return unless @context_manager.needs_compaction?(@conversation)
247
341
 
248
- est = @context_manager.estimated_tokens(@conversation.messages)
342
+ est = @context_manager.estimated_tokens(@conversation)
249
343
  RubynCode::Debug.token(
250
344
  "Context over threshold (#{est}) — running compaction"
251
345
  )
252
346
  @context_manager.check_compaction!(@conversation)
253
347
 
254
- after = @context_manager.estimated_tokens(@conversation.messages)
348
+ after = @context_manager.estimated_tokens(@conversation)
255
349
  RubynCode::Debug.token("Compacted: #{est} → #{after} tokens")
256
350
  rescue StandardError => e
257
351
  RubynCode::Debug.warn("Compaction failed: #{e.message}")
@@ -266,8 +360,8 @@ module RubynCode
266
360
  @max_tokens_override = Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
267
361
  end
268
362
 
269
- def max_iterations_warning
270
- warning = "Reached maximum iteration limit (#{MAX_ITERATIONS}). " \
363
+ def max_iterations_warning(limit = MAX_ITERATIONS)
364
+ warning = "Reached maximum iteration limit (#{limit}). " \
271
365
  'The conversation may be incomplete. Please review the ' \
272
366
  'current state and continue if needed.'
273
367
  @conversation.add_assistant_message([{ type: 'text', text: warning }])
@@ -61,6 +61,14 @@ module RubynCode
61
61
  end
62
62
  end
63
63
 
64
+ def extract_stop_details(response)
65
+ if response.respond_to?(:stop_details)
66
+ response.stop_details
67
+ elsif response.is_a?(Hash)
68
+ response[:stop_details] || response['stop_details']
69
+ end
70
+ end
71
+
64
72
  def recover_truncated_response(response)
65
73
  @max_tokens_override ||= Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
66
74
  @conversation.add_assistant_message(response_content(response))
@@ -10,7 +10,7 @@ module RubynCode
10
10
  module SystemPromptBuilder # rubocop:disable Metrics/ModuleLength -- heavily extracted, residual 3 lines over
11
11
  include Prompts
12
12
 
13
- INSTRUCTION_FILES = %w[RUBYN.md CLAUDE.md AGENT.md].freeze
13
+ INSTRUCTION_FILES = %w[RUBYN.md CLAUDE.md AGENTS.md AGENT.md].freeze
14
14
 
15
15
  private
16
16
 
@@ -19,16 +19,49 @@ module RubynCode
19
19
  parts << PLAN_MODE_PROMPT if @plan_mode
20
20
  parts << "Working directory: #{@project_root}" if @project_root
21
21
  append_response_mode(parts)
22
+ static = static_prompt_sections
23
+ parts << static unless static.empty?
24
+ parts.join("\n")
25
+ end
26
+
27
+ # The static sections hit SQLite (memories, instincts) and walk the
28
+ # filesystem (instruction files, profile), so they're assembled once
29
+ # per user turn instead of on every iteration of the tool loop. Only
30
+ # the plan-mode flag and response-mode line vary mid-turn, and those
31
+ # stay in build_system_prompt.
32
+ def static_prompt_sections
33
+ @static_prompt_sections ||= build_static_prompt_sections
34
+ end
35
+
36
+ def build_static_prompt_sections
37
+ parts = []
22
38
  append_project_profile(parts)
23
39
  append_codebase_index(parts)
24
40
  append_memories(parts)
25
41
  append_project_instructions(parts)
26
42
  append_instincts(parts)
27
43
  append_skills(parts)
44
+ append_chisel_ruleset(parts)
28
45
  append_deferred_tools(parts)
29
46
  parts.join("\n")
30
47
  end
31
48
 
49
+ # Chisel's "write the minimum that works" ruleset, injected only when the
50
+ # user has turned it on (chisel_mode != off). Guarded so a config or
51
+ # resolution error never breaks prompt assembly.
52
+ def append_chisel_ruleset(parts)
53
+ section = Chisel.prompt_section
54
+ parts << "\n#{section}" unless section.empty?
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ # Called at the start of each user turn so memory, instruction, and
60
+ # tool changes made between turns show up in the next prompt.
61
+ def reset_system_prompt_cache!
62
+ @static_prompt_sections = nil
63
+ end
64
+
32
65
  def append_response_mode(parts)
33
66
  text = last_user_text
34
67
  return if text.empty?
@@ -68,6 +101,9 @@ module RubynCode
68
101
  return unless index&.nodes&.any?
69
102
 
70
103
  parts << "\n## #{index.to_structural_summary}"
104
+ parts << 'This project has a code graph. To locate or understand code, call `code_graph` FIRST — ' \
105
+ 'it returns matching symbols with source, callers, and callees in one call. ' \
106
+ 'Fall back to grep/glob only when code_graph finds nothing.'
71
107
  rescue StandardError
72
108
  nil
73
109
  end
@@ -182,7 +218,9 @@ module RubynCode
182
218
 
183
219
  db = DB::Connection.instance
184
220
  search = Memory::Search.new(db, project_path: @project_root)
185
- recent = search.recent(limit: 20)
221
+ # touch: false — assembling the prompt is not a memory "access";
222
+ # touching here would issue a SQLite write and inflate access counts.
223
+ recent = search.recent(limit: 20, touch: false)
186
224
  return '' if recent.empty?
187
225
 
188
226
  recent.map { |m| format_memory(m) }.join("\n")
@@ -52,6 +52,16 @@ module RubynCode
52
52
  Tools::Registry.all.select { |t| PLAN_MODE_RISK_LEVELS.include?(t::RISK_LEVEL) }.map(&:to_schema)
53
53
  end
54
54
 
55
+ # Tool definitions for the next LLM call: plan mode narrows to
56
+ # read-only tools; a per-turn allowed-tools override (custom-command
57
+ # frontmatter, set via Loop#allowed_tools_override=) narrows by name.
58
+ def filtered_tool_definitions
59
+ defs = @plan_mode ? read_only_tool_definitions : tool_definitions
60
+ return defs unless @allowed_tools_override
61
+
62
+ DynamicToolSchema.filter(defs, active_names: @allowed_tools_override)
63
+ end
64
+
55
65
  # -- tool dispatch with budget + signals
56
66
  def process_tool_calls(tool_calls)
57
67
  aggregate_chars = 0
@@ -116,7 +126,9 @@ module RubynCode
116
126
 
117
127
  def execute_tool(tool_name, tool_input)
118
128
  discover_tool(tool_name)
119
- @hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
129
+ pre_decision = @hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
130
+ raise RubynCode::UserDeniedError, pre_decision[:reason] if pre_decision.is_a?(Hash) && pre_decision[:deny]
131
+
120
132
  result = dispatch_tool(tool_name, tool_input)
121
133
  @hook_runner.fire(:post_tool_use, tool_name: tool_name, tool_input: tool_input, result: result)
122
134
  signal_decision_compactor(tool_name, tool_input, result)
@@ -3,7 +3,6 @@
3
3
  require 'securerandom'
4
4
  require 'digest'
5
5
  require 'base64'
6
- require 'faraday'
7
6
  require 'json'
8
7
 
9
8
  module RubynCode
@@ -163,6 +162,7 @@ module RubynCode
163
162
  end
164
163
 
165
164
  def http_client
165
+ require 'faraday'
166
166
  @http_client ||= Faraday.new do |f|
167
167
  f.options.timeout = 30
168
168
  f.options.open_timeout = 10
@@ -11,13 +11,27 @@ module RubynCode
11
11
  EXPIRY_BUFFER_SECONDS = 300 # 5 minutes
12
12
  KEYCHAIN_SERVICE = 'Claude Code-credentials'
13
13
 
14
+ # Strategy chain: each method returns a token hash or nil.
15
+ # First non-nil result wins. Adding a new auth source is a one-line entry.
16
+ LOAD_STRATEGIES = %i[
17
+ load_from_keychain
18
+ load_from_credentials_file
19
+ load_from_file
20
+ load_from_env
21
+ ].freeze
22
+
14
23
  class << self
15
24
  # Load tokens with fallback chain:
16
25
  # 1. macOS Keychain (Claude Code's OAuth token)
17
- # 2. Local YAML file (~/.rubyn-code/tokens.yml)
18
- # 3. ANTHROPIC_API_KEY environment variable
26
+ # 2. Claude Code credentials file (~/.claude/.credentials.json)
27
+ # 3. Local YAML file (~/.rubyn-code/tokens.yml)
28
+ # 4. ANTHROPIC_API_KEY environment variable
19
29
  def load
20
- load_from_keychain || load_from_file || load_from_env
30
+ LOAD_STRATEGIES.each do |strategy|
31
+ result = send(strategy)
32
+ return result if result
33
+ end
34
+ nil
21
35
  end
22
36
 
23
37
  # Load API key for a given provider. Anthropic uses the full fallback chain.
@@ -68,7 +82,12 @@ module RubynCode
68
82
  end
69
83
 
70
84
  def valid?
71
- tokens = self.load
85
+ valid_tokens?(self.load)
86
+ end
87
+
88
+ # Validate an already-loaded token hash without re-reading the
89
+ # keychain — lets callers cache the result of `load`.
90
+ def valid_tokens?(tokens)
72
91
  return false unless tokens&.fetch(:access_token, nil)
73
92
  return true if tokens[:type] == :api_key
74
93
  return true unless tokens[:expires_at]
@@ -88,6 +107,7 @@ module RubynCode
88
107
  default
89
108
  end
90
109
 
110
+ # macOS only: read from Keychain Services
91
111
  def load_from_keychain
92
112
  return nil unless RUBY_PLATFORM.include?('darwin')
93
113
 
@@ -102,6 +122,21 @@ module RubynCode
102
122
  nil
103
123
  end
104
124
 
125
+ # Linux/other: Claude Code stores OAuth in a plain JSON file
126
+ def load_from_credentials_file
127
+ path = Config::Defaults::CLAUDE_CREDENTIALS_FILE
128
+ return nil unless File.exist?(path)
129
+
130
+ warn_insecure_permissions(path)
131
+
132
+ oauth = JSON.parse(File.read(path))['claudeAiOauth']
133
+ return nil unless oauth&.dig('accessToken')
134
+
135
+ build_keychain_tokens(oauth)
136
+ rescue StandardError
137
+ nil
138
+ end
139
+
105
140
  def build_keychain_tokens(oauth)
106
141
  {
107
142
  access_token: oauth['accessToken'],
@@ -137,6 +172,16 @@ module RubynCode
137
172
  { access_token: api_key, refresh_token: nil, expires_at: nil, type: :api_key, source: :env }
138
173
  end
139
174
 
175
+ # Warn when credentials file has loose permissions (no system ACLs on Linux)
176
+ def warn_insecure_permissions(path)
177
+ mode = File.stat(path).mode & 0o777
178
+ return if mode == 0o600
179
+
180
+ warn "[rubyn-code] WARNING: #{path} has mode #{format('%04o', mode)}, expected 0600"
181
+ rescue StandardError
182
+ nil # best-effort — don't fail a token load over a stat
183
+ end
184
+
140
185
  def write_tokens_file(data)
141
186
  File.write(tokens_path, YAML.dump(data))
142
187
  File.chmod(0o600, tokens_path)
@@ -212,7 +212,7 @@ module RubynCode
212
212
  tokens = extract_token_counts(cm)
213
213
  return if tokens.values.all?(&:zero?)
214
214
 
215
- model = @llm_client.respond_to?(:model) ? @llm_client.model : 'claude-sonnet-4-6'
215
+ model = @llm_client.respond_to?(:model) ? @llm_client.model : 'claude-sonnet-5'
216
216
  @total_cost += Observability::CostCalculator.calculate(model: model, **tokens)
217
217
  rescue StandardError
218
218
  # Non-critical — cost tracking is best-effort
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Checkpoint
5
+ # A :pre_tool_use hook that snapshots a file's original contents into the
6
+ # current checkpoint just before a mutating tool changes it. Only the
7
+ # file-mutating tools are watched; everything else is ignored.
8
+ class Hook
9
+ MUTATING_TOOLS = %w[write_file edit_file].freeze
10
+
11
+ # @param manager [Checkpoint::Manager]
12
+ def initialize(manager:)
13
+ @manager = manager
14
+ end
15
+
16
+ # @return [nil] never blocks the tool (returns no deny decision)
17
+ def call(tool_name:, tool_input: {}, **_kwargs)
18
+ return nil unless MUTATING_TOOLS.include?(tool_name.to_s)
19
+
20
+ path = tool_input[:path] || tool_input['path']
21
+ @manager.record_file(path) if path
22
+ nil
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module RubynCode
6
+ # Checkpoints let a user rewind a session, mirroring Claude Code's /rewind.
7
+ # A checkpoint is taken at the start of each user turn and captures:
8
+ # - the conversation state (so chat can be rolled back), and
9
+ # - the original contents of every file mutated during that turn (so code
10
+ # can be restored).
11
+ #
12
+ # File contents are captured lazily by Checkpoint::Hook on :pre_tool_use,
13
+ # just before a mutating tool runs, so only files that actually change are
14
+ # snapshotted.
15
+ module Checkpoint
16
+ # Marker stored for a path that did not exist when first touched, so a
17
+ # rewind deletes it rather than recreating empty content.
18
+ ABSENT = :absent
19
+
20
+ class Manager
21
+ MAX_CHECKPOINTS = 30
22
+
23
+ def initialize(project_root:)
24
+ @project_root = project_root
25
+ @checkpoints = []
26
+ @seq = 0
27
+ @current = nil
28
+ end
29
+
30
+ # Open a new checkpoint for a user turn. Captures the conversation as it
31
+ # stands before the agent acts.
32
+ #
33
+ # @param label [String] short description (usually the user's message)
34
+ # @param conversation [Agent::Conversation]
35
+ # @return [Integer] the checkpoint id
36
+ def checkpoint!(label:, conversation:)
37
+ @seq += 1
38
+ @current = {
39
+ id: @seq,
40
+ label: summarize(label),
41
+ messages: Array(conversation.messages).dup,
42
+ files: {}
43
+ }
44
+ @checkpoints << @current
45
+ @checkpoints.shift while @checkpoints.size > MAX_CHECKPOINTS
46
+ @seq
47
+ end
48
+
49
+ # Record a file's original contents before it is mutated (once per
50
+ # checkpoint per path). No-op when no checkpoint is open.
51
+ #
52
+ # @param path [String] absolute or project-relative path
53
+ # @return [void]
54
+ def record_file(path)
55
+ return unless @current && path
56
+
57
+ abs = File.expand_path(path.to_s, @project_root)
58
+ return if @current[:files].key?(abs)
59
+
60
+ @current[:files][abs] = File.file?(abs) ? File.read(abs) : ABSENT
61
+ rescue StandardError => e
62
+ RubynCode::Debug.warn("Checkpoint capture failed for #{path}: #{e.message}")
63
+ end
64
+
65
+ # @return [Array<Hash>] {id:, label:, files:} newest last
66
+ def list
67
+ @checkpoints.map { |c| { id: c[:id], label: c[:label], files: c[:files].size } }
68
+ end
69
+
70
+ def empty? = @checkpoints.empty?
71
+
72
+ def latest_id = @checkpoints.last&.fetch(:id)
73
+
74
+ # Restore a checkpoint. Scope :both (default), :code, or :chat.
75
+ # Checkpoints newer than the restored one are discarded.
76
+ #
77
+ # @return [Hash, nil] summary { id:, files_restored: } or nil if not found
78
+ def restore(id, conversation, scope: :both)
79
+ checkpoint = @checkpoints.find { |c| c[:id] == id }
80
+ return nil unless checkpoint
81
+
82
+ restored_files = restore_files(checkpoint) if %i[both code].include?(scope)
83
+ conversation.replace!(checkpoint[:messages].dup) if %i[both chat].include?(scope)
84
+
85
+ @checkpoints.reject! { |c| c[:id] > id }
86
+ @current = nil
87
+ { id: id, files_restored: restored_files || 0 }
88
+ end
89
+
90
+ private
91
+
92
+ def restore_files(checkpoint)
93
+ checkpoint[:files].each do |abs, content|
94
+ if content == ABSENT
95
+ FileUtils.rm_f(abs)
96
+ else
97
+ File.write(abs, content)
98
+ end
99
+ end
100
+ checkpoint[:files].size
101
+ end
102
+
103
+ def summarize(label)
104
+ text = label.to_s.tr("\n", ' ').strip
105
+ text.length > 60 ? "#{text[0, 57]}…" : text
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Chisel
5
+ # Harvests inline deferral markers from the codebase. A marker is a code
6
+ # comment whose text begins with the lowercase tag "chisel:" and records a
7
+ # simplification you consciously postponed (e.g. a comment reading
8
+ # "chisel: collapse this adapter once a second caller exists").
9
+ #
10
+ # Finding them is a deterministic grep — stdlib does it, so there's no LLM
11
+ # round-trip here (Chisel's own ladder, applied to Chisel).
12
+ module Debt
13
+ Item = Data.define(:file, :line, :note)
14
+
15
+ # The whole line must be a comment whose first token is the lowercase tag:
16
+ # optional indentation, a `#` or `//` leader, then `chisel:`, then the note.
17
+ # Anchoring to line-start means a `chisel:` substring inside a string
18
+ # literal or a trailing code comment is NOT harvested — only a marker on its
19
+ # own comment line is. Case-sensitive on purpose, so a descriptive comment
20
+ # that merely starts with "Chisel:" is not a marker.
21
+ MARKER = %r{\A\s*(?:#|//)\s*chisel:\s*(\S.*)}
22
+
23
+ SCAN_EXTENSIONS = %w[.rb .rake .erb .ru .gemspec].freeze
24
+ SKIP_DIRS = %w[.git node_modules vendor coverage tmp log].freeze
25
+
26
+ module_function
27
+
28
+ # @param root [String, nil] project root to scan
29
+ # @return [Array<Item>] markers found, in file/line order
30
+ def scan(root)
31
+ return [] unless root
32
+
33
+ base = File.expand_path(root)
34
+ return [] unless Dir.exist?(base)
35
+
36
+ source_files(base).flat_map { |path| scan_file(base, path) }
37
+ end
38
+
39
+ # @param base [String] expanded project root (no trailing slash)
40
+ # @return [Array<String>] absolute paths of scannable source files
41
+ def source_files(base)
42
+ pattern = File.join(base, '**', "*{#{SCAN_EXTENSIONS.join(',')}}")
43
+ Dir.glob(pattern).reject { |path| skip?(base, path) }.sort
44
+ end
45
+
46
+ def skip?(base, path)
47
+ rel = path.delete_prefix("#{base}/")
48
+ SKIP_DIRS.any? { |dir| rel == dir || rel.start_with?("#{dir}/") || rel.include?("/#{dir}/") }
49
+ end
50
+
51
+ # @return [Array<Item>] markers in a single file ([] if it can't be read)
52
+ def scan_file(base, path)
53
+ rel = path.delete_prefix("#{base}/")
54
+ items = []
55
+ File.foreach(path).with_index(1) do |line, number|
56
+ match = MARKER.match(line)
57
+ items << Item.new(file: rel, line: number, note: match[1].strip) if match
58
+ end
59
+ items
60
+ rescue StandardError
61
+ []
62
+ end
63
+ end
64
+ end
65
+ end