rubyn-code 0.7.0 → 0.9.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -17
  3. data/lib/rubyn_code/agent/conversation.rb +11 -1
  4. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  5. data/lib/rubyn_code/agent/llm_caller.rb +5 -1
  6. data/lib/rubyn_code/agent/loop.rb +57 -3
  7. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +3 -0
  9. data/lib/rubyn_code/agent/tool_processor.rb +10 -0
  10. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  11. data/lib/rubyn_code/cli/commands/context.rb +27 -0
  12. data/lib/rubyn_code/cli/commands/custom_command.rb +44 -2
  13. data/lib/rubyn_code/cli/commands/custom_loader.rb +36 -5
  14. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  15. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  16. data/lib/rubyn_code/cli/commands/mcp.rb +32 -8
  17. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  18. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  19. data/lib/rubyn_code/cli/first_run.rb +1 -1
  20. data/lib/rubyn_code/cli/mention_expander.rb +19 -0
  21. data/lib/rubyn_code/cli/repl.rb +31 -1
  22. data/lib/rubyn_code/cli/repl_commands.rb +1 -1
  23. data/lib/rubyn_code/cli/repl_setup.rb +8 -6
  24. data/lib/rubyn_code/config/defaults.rb +2 -1
  25. data/lib/rubyn_code/config/schema.json +5 -0
  26. data/lib/rubyn_code/config/settings.rb +4 -2
  27. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  28. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  29. data/lib/rubyn_code/index/codebase_index.rb +64 -3
  30. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  31. data/lib/rubyn_code/learning/injector.rb +1 -2
  32. data/lib/rubyn_code/llm/adapters/anthropic.rb +107 -17
  33. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  34. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  35. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  36. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  37. data/lib/rubyn_code/llm/client.rb +16 -3
  38. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  39. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  40. data/lib/rubyn_code/llm/model_router.rb +4 -4
  41. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  42. data/lib/rubyn_code/memory/session_persistence.rb +1 -1
  43. data/lib/rubyn_code/observability/cost_calculator.rb +6 -3
  44. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  45. data/lib/rubyn_code/tasks/models.rb +0 -16
  46. data/lib/rubyn_code/teams/teammate.rb +0 -15
  47. data/lib/rubyn_code/tools/RUBYN.md +3 -3
  48. data/lib/rubyn_code/tools/bash.rb +3 -3
  49. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  50. data/lib/rubyn_code/tools/executor.rb +6 -1
  51. data/lib/rubyn_code/tools/phone_a_friend.rb +135 -0
  52. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  53. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  54. data/lib/rubyn_code/version.rb +1 -1
  55. data/lib/rubyn_code.rb +14 -8
  56. data/skills/rubyn_self_test.md +140 -0
  57. metadata +11 -7
  58. data/lib/rubyn_code/context/context_budget.rb +0 -183
  59. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  60. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  61. data/lib/rubyn_code/llm/adapters/token_caching.rb +0 -54
  62. data/lib/rubyn_code/llm/streaming.rb +0 -10
  63. data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5cbf6f4737790408893a904d64412c2dd695e81a2cd2e5960b4da3196e48f4f
4
- data.tar.gz: f0e9b80f10c77d6eb6c357db7518851344670653546d5eca2784d017d490608c
3
+ metadata.gz: d43be98f4e24cc9bc78bc541ed0554731ed88985adb56ec5700d643dc4fd2ba2
4
+ data.tar.gz: d6adda167ad528fde80900dfb4c5d7114029695c4ae83f814ad2130b2f531781
5
5
  SHA512:
6
- metadata.gz: bc4553f04fbac43903e1515cec43cf595245d36223bca70342ddafb2a2920f38f502b356eb353b4b6e5284d128688e07c015e0a3ebb4aa06f7029fac0ba42a37
7
- data.tar.gz: b9f8ab09a40e073bff1657ef9d829b7551a5ca56ce9f77b65bd59b835d079665d3663faf92ccf44fd84d4ff3674f835c09d102b4f449c175c77bcafb466dd934
6
+ metadata.gz: 3352ad014212276c17d5a96adb3d3dcf43165dffb77b8a95d01ce22bf3fdd57786abca291c4604e937cef26b070b3d43ae791430da30d84baa49a76e753d8f28
7
+ data.tar.gz: 767c3be54cee4d94e157dd7ae73287c5306159586f3057eac199175aa0ac79aba3ef35046cc4eef6936c6f80802277bf578e5588c35b2fd8940d8aa58037c07e
data/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  <a href="https://github.com/MatthewSuttles/rubyn-code/actions/workflows/ci.yml"><img src="https://github.com/MatthewSuttles/rubyn-code/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
16
16
  </p>
17
17
 
18
- Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code for anti-patterns, and build entire features — all context-aware with your schema, routes, and specs. Powered by Claude Opus 4.6, running on your existing Claude subscription.
18
+ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code for anti-patterns, and build entire features — all context-aware with your schema, routes, and specs. Powered by Claude Opus 5, running on your existing Claude subscription.
19
19
 
20
20
  <img width="1230" height="280" alt="image" src="https://github.com/user-attachments/assets/14e07ce8-def0-4a8f-ac89-46661361a4eb" />
21
21
 
@@ -31,7 +31,7 @@ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code f
31
31
  - [Quick Start](#quick-start)
32
32
  - [What Can Rubyn Do?](#what-can-rubyn-do)
33
33
  - [VS Code Extension](#vs-code-extension)
34
- - [29 Built-in Tools](#29-built-in-tools)
34
+ - [32 Built-in Tools](#32-built-in-tools)
35
35
  - [MCP — External Tool Servers](#mcp--external-tool-servers)
36
36
  - [Codebase Indexing](#codebase-indexing)
37
37
  - [112 Best Practice Skills](#112-best-practice-skills)
@@ -69,6 +69,22 @@ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code f
69
69
  - **IDE-ready** — works in the terminal and inside VS Code with full bidirectional communication
70
70
  - **Extensible** — connect external tool servers via MCP, add custom skills, or wire up your own providers
71
71
 
72
+ ## Claude Code Feature Parity
73
+
74
+ Phase 4 ships nine parity features with Claude Code:
75
+
76
+ - **Extended thinking** — `/think <budget>` toggles per-session reasoning; the Anthropic adapter emits `thinking: {type: 'adaptive'}` on Claude 4.6+ models (`{type: 'enabled', budget_tokens}` on older ones).
77
+ - **Reasoning effort** — `/effort <low|medium|high|xhigh|max>` sets the request-level reasoning depth; the Anthropic adapter emits `output_config: {effort}` on the wire. GA on Claude 4.6+ (Opus 4.6/4.7/4.8/5, Sonnet 4.6/5, Fable 5); `xhigh` needs Opus 4.7+ / Sonnet 5 / Fable 5. Model default is `high`.
78
+ - **Task budgets (beta)** — the agent loop's remaining token budget is sent as `output_config: {task_budget: {type: 'tokens', total}}` on supported models (Fable 5, Sonnet 5, Opus 5, Opus 4.7/4.8), advisory pacing only — `max_tokens` stays the enforced per-response cap.
79
+ - **Image / vision input** — `@chart.png` (and `.jpg` / `.jpeg` / `.gif` / `.webp`) becomes a real image content block attached to the user turn; Anthropic and OpenAI each emit their native shape.
80
+ - **TodoWrite live checklist** — `TodoWrite` tool; the checklist refreshes above the spinner on every tool result so you see in-turn progress at a glance.
81
+ - **Custom-command frontmatter** — `argument-hint`, `allowed-tools`, and `model:` keys in `~/.rubyn-code/commands/*.md`; the loop honors the per-prompt tool restriction and model override.
82
+ - **`.mcp.json` auto-discovery** — project-root MCP servers flow into the REPL automatically; `/mcp` shows entries prefixed `[project]` or `[user]`. OAuth for URL transports is still deferred.
83
+ - **`/export` transcript** — `/export <path>` writes the conversation as Markdown (default) or `--jsonl` for tooling.
84
+ - **Fable 5 refusal handling** — a declined request (`stop_reason: "refusal"`) surfaces a clear message instead of an empty turn; `claude-fable-5` / `claude-mythos-5` requests opt into a server-side Opus 4.8 fallback by default, so most refusals are transparently re-served in the same call.
85
+
86
+ See `docs/04-feature-parity/` for per-feature design + tasks docs.
87
+
72
88
  ## Install
73
89
 
74
90
  Requires **Ruby 4.0.2+**. Install with your latest Ruby, then pin it so it works in every project:
@@ -199,7 +215,7 @@ Rubyn Code includes a VS Code extension that provides a full IDE experience with
199
215
  - Full session management — resume, list, fork, and reset conversations
200
216
  - Structured code review feedback with severity ratings
201
217
  - IDE config get/set for persistent settings
202
- - All 29 tools available, including MCP tools
218
+ - All 32 tools available, including MCP tools
203
219
 
204
220
  **Permission modes:**
205
221
 
@@ -210,19 +226,19 @@ Rubyn Code includes a VS Code extension that provides a full IDE experience with
210
226
 
211
227
  The extension communicates over 19 RPC methods: `initialize`, `prompt`, `cancel`, `review`, `approveToolUse`, `acceptEdit`, `session/*`, `config/*`, `models/list`, `plan/propose`, `plan/interview/*` (chat-resident [megaplan](#megaplan--phased-planning)), `recover_ci`, and `shutdown`.
212
228
 
213
- ## 29 Built-in Tools
229
+ ## 32 Built-in Tools
214
230
 
215
231
  | Category | Tools |
216
232
  |----------|-------|
217
233
  | **File I/O** | `read_file`, `write_file`, `edit_file` |
218
- | **Search** | `glob`, `grep` |
234
+ | **Search** | `glob`, `grep`, `code_graph` |
219
235
  | **Execution** | `bash` (sandboxed, dangerous commands blocked) |
220
236
  | **Web** | `web_search`, `web_fetch` |
221
237
  | **Git** | `git_status`, `git_diff`, `git_log`, `git_commit` |
222
238
  | **Rails** | `rails_generate`, `db_migrate`, `run_specs`, `bundle_install`, `bundle_add` |
223
239
  | **Review** | `review_pr` (diff-based best practice code review) |
224
- | **Agents** | `spawn_agent`, `spawn_teammate`, `background_run` |
225
- | **Context** | `compact`, `load_skill`, `task` |
240
+ | **Agents** | `spawn_agent`, `spawn_teammate`, `background_run`, `phone_a_friend` |
241
+ | **Context** | `compact`, `load_skill`, `task`, `todo_write` |
226
242
  | **Memory** | `memory_search`, `memory_write` |
227
243
  | **Teams** | `send_message`, `read_inbox` |
228
244
  | **Interactive** | `ask_user` (ask clarifying questions mid-task) |
@@ -502,6 +518,18 @@ rubyn > Send alice a message to write specs for the User model
502
518
 
503
519
  Teammates run in background threads with their own agent loop and mailbox.
504
520
 
521
+ ### Phone a Friend (second opinion)
522
+
523
+ When the agent is stuck or weighing two approaches, the `phone_a_friend` tool asks a *different* model for a one-shot second opinion. The friend is picked for perspective diversity: the top-tier model of another configured provider whose API key is present (a genuinely different model family), falling back to the active provider's top tier. The friend gets no tools and no conversation history — only the question and whatever context the agent chooses to send — and its answer comes back as plain text labeled with the provider and model that gave it.
524
+
525
+ ```
526
+ rubyn > I keep going back and forth on STI vs polymorphic here...
527
+
528
+ [tool] phone_a_friend
529
+ ## Second Opinion — openai/gpt-5.4
530
+ Commit to the polymorphic association. The key reason: ...
531
+ ```
532
+
505
533
  ## GOLEM — Autonomous Daemon
506
534
 
507
535
  GOLEM is an always-on autonomous agent that claims tasks from a queue and works through them independently. It runs a full agent loop per task with access to all tools, MCP servers, and memory.
@@ -675,7 +703,7 @@ Rubyn snapshots a checkpoint at the start of every turn — capturing the conver
675
703
  | 2 | Token file | `~/.rubyn-code/tokens.yml` |
676
704
  | 3 | Environment | `export ANTHROPIC_API_KEY=sk-ant-...` |
677
705
 
678
- Works with Claude Pro, Max, Team, and Enterprise. Default model: **Claude Opus 4.6**.
706
+ Works with Claude Pro, Max, Team, and Enterprise. Default model: **Claude Opus 5**.
679
707
 
680
708
  ### OpenAI
681
709
 
@@ -693,7 +721,7 @@ Add a provider and its API key in one command:
693
721
  /provider add groq https://api.groq.com/openai/v1 --key gsk-xxx --models llama-3.3-70b
694
722
 
695
723
  # For Anthropic-format proxies (e.g., Bedrock, custom gateways)
696
- /provider add my-proxy https://proxy.example.com/v1 --format anthropic --key sk-xxx --models claude-sonnet-4-6
724
+ /provider add my-proxy https://proxy.example.com/v1 --format anthropic --key sk-xxx --models claude-sonnet-5
697
725
 
698
726
  # Update a key later
699
727
  /provider set-key groq gsk-new-key
@@ -724,7 +752,7 @@ providers:
724
752
  base_url: https://proxy.example.com/v1
725
753
  env_key: PROXY_API_KEY
726
754
  models:
727
- top: claude-sonnet-4-6
755
+ top: claude-sonnet-5
728
756
  ```
729
757
 
730
758
  Then switch with `/model groq:llama-3.3-70b`.
@@ -751,7 +779,7 @@ Local providers (Ollama, LM Studio) running on `localhost`/`127.0.0.1` don't req
751
779
  │ Layer 5: Skills (112 best practice docs, on-demand loading) │
752
780
  │ Layer 4: Context Management (3-layer compression pipeline) │
753
781
  │ Layer 3: Permissions (tiered access + deny lists + hooks) │
754
- │ Layer 2: Tool System (29 tools, dispatch map registry) │
782
+ │ Layer 2: Tool System (32 tools, dispatch map registry) │
755
783
  │ Layer 1: THE AGENT LOOP (while tool_use → execute → repeat) │
756
784
  └──────────────────────────────────────────────────────────────┘
757
785
  ```
@@ -764,7 +792,7 @@ These must match a provider defined in the `providers` section (or a built-in li
764
792
  ```yaml
765
793
  # ~/.rubyn-code/config.yml (global)
766
794
  provider: anthropic # default provider on startup
767
- model: claude-opus-4-6 # default model on startup
795
+ model: claude-opus-5 # default model on startup
768
796
  permission_mode: allow_read
769
797
  session_budget: 5.00
770
798
  daily_budget: 10.00
@@ -782,15 +810,15 @@ Rubyn can automatically route tasks to different AI models based on complexity.
782
810
  ```yaml
783
811
  # ~/.rubyn-code/config.yml
784
812
  provider: anthropic
785
- model: claude-opus-4-6
813
+ model: claude-opus-5
786
814
 
787
815
  providers:
788
816
  anthropic:
789
817
  env_key: ANTHROPIC_API_KEY
790
818
  models:
791
819
  cheap: claude-haiku-4-5 # file search, git ops, formatting
792
- mid: claude-sonnet-4-6 # code gen, specs, refactors, reviews
793
- top: claude-opus-4-6 # architecture, security, complex work
820
+ mid: claude-sonnet-5 # code gen, specs, refactors, reviews
821
+ top: claude-opus-5 # architecture, security, complex work
794
822
 
795
823
  openai:
796
824
  env_key: OPENAI_API_KEY
@@ -822,8 +850,8 @@ providers:
822
850
  | Tier | Task types | Default model |
823
851
  |------|-----------|---------------|
824
852
  | **cheap** | File search, git ops, formatting, summaries | `claude-haiku-4-5` |
825
- | **mid** | Code generation, specs, refactors, code review, bug fixes | `claude-sonnet-4-6` |
826
- | **top** | Architecture, security review, complex refactors, planning | `claude-opus-4-6` |
853
+ | **mid** | Code generation, specs, refactors, code review, bug fixes | `claude-sonnet-5` |
854
+ | **top** | Architecture, security review, complex refactors, planning | `claude-opus-5` |
827
855
 
828
856
  You can also set custom pricing per model so `/cost` reports accurate spending for third-party providers.
829
857
 
@@ -14,7 +14,10 @@ module RubynCode
14
14
 
15
15
  # Append a user turn to the conversation.
16
16
  #
17
- # @param content [String]
17
+ # @param content [String, Array<Hash>] either a plain string or an
18
+ # array of content blocks (text, image, etc.). Strings get wrapped
19
+ # as `[{ type: 'text', text: content }]` only if there's a planned
20
+ # Array; we keep raw String for backward compatibility with the API.
18
21
  # @return [Hash] the appended message
19
22
  def add_user_message(content)
20
23
  message = { role: 'user', content: content }
@@ -155,6 +158,13 @@ module RubynCode
155
158
  reset_derived_state!
156
159
  end
157
160
 
161
+ # Alias for `messages`. Common shorthand for callers that want to
162
+ # treat the conversation as an Array of message hashes.
163
+ def to_a
164
+ @messages
165
+ end
166
+ alias to_ary to_a
167
+
158
168
  private
159
169
 
160
170
  # Derived bookkeeping kept in sync with @messages so hot paths stay
@@ -8,7 +8,7 @@ module RubynCode
8
8
  # prompt overhead by 30-50%.
9
9
  module DynamicToolSchema
10
10
  BASE_TOOLS = %w[
11
- read_file write_file edit_file glob grep bash
11
+ read_file write_file edit_file glob grep code_graph bash
12
12
  ].freeze
13
13
 
14
14
  TASK_TOOLS = {
@@ -25,7 +25,7 @@ module RubynCode
25
25
  def build_llm_opts
26
26
  opts = {
27
27
  messages: @conversation.to_api_format,
28
- tools: @plan_mode ? read_only_tool_definitions : tool_definitions,
28
+ tools: filtered_tool_definitions,
29
29
  system: build_system_prompt,
30
30
  on_text: @on_text
31
31
  }
@@ -36,6 +36,8 @@ module RubynCode
36
36
  total: UsageTracker::TASK_BUDGET_TOTAL, remaining: @task_budget_remaining
37
37
  }
38
38
  end
39
+ budget = @llm_client.respond_to?(:thinking_budget_tokens) ? @llm_client.thinking_budget_tokens.to_i : 0
40
+ opts[:thinking] = { budget_tokens: budget } if budget.positive?
39
41
  opts
40
42
  end
41
43
 
@@ -45,6 +47,8 @@ module RubynCode
45
47
  # Falls back to nil (use client's default) if routing fails.
46
48
  # -- guard clauses for provider/mode checks
47
49
  def routed_model
50
+ return @model_override if @model_override
51
+
48
52
  return nil if manual_model_mode?
49
53
 
50
54
  last_user = last_user_message_text
@@ -53,8 +53,11 @@ module RubynCode
53
53
  # response is produced or the iteration limit is reached.
54
54
  #
55
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.
56
59
  # @return [String] the final assistant text response
57
- def send_message(user_input)
60
+ def send_message(user_input, blocks: nil)
58
61
  initialize_session!
59
62
  check_user_feedback(user_input)
60
63
  drain_background_notifications
@@ -62,7 +65,7 @@ module RubynCode
62
65
  @decision_compactor&.detect_topic_switch(user_input)
63
66
  @skill_ttl&.tick!
64
67
  autoload_triggered_skills(user_input)
65
- @conversation.add_user_message(user_input)
68
+ append_user_message(user_input, blocks)
66
69
  reset_system_prompt_cache!
67
70
  reset_iteration_state
68
71
 
@@ -79,8 +82,38 @@ module RubynCode
79
82
  max_iterations_warning(iteration)
80
83
  end
81
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
111
+ end
112
+
82
113
  private
83
114
 
115
+ # Overrides the region above. The internal helpers below are private.
116
+
84
117
  # Decide whether the loop should run another iteration after `iteration`
85
118
  # turns. Normally capped at MAX_ITERATIONS, but while a Stop hook (e.g. an
86
119
  # active /goal) is keeping the agent alive we extend up to a hard ceiling
@@ -119,6 +152,8 @@ module RubynCode
119
152
  @tool_wrapper = opts[:tool_wrapper]
120
153
  @decision_compactor = build_decision_compactor
121
154
  @skill_ttl = Skills::TtlManager.new
155
+ @todo_store = opts.fetch(:todo_store, Tools::TodoStore.new)
156
+ @tool_executor.todo_store = @todo_store
122
157
  @session_initialized = false
123
158
  end
124
159
 
@@ -133,6 +168,7 @@ module RubynCode
133
168
  def initialize_session!
134
169
  return if @session_initialized || !@project_root
135
170
 
171
+ @todo_store&.clear
136
172
  @session_initialized = true
137
173
  build_project_profile!
138
174
  build_codebase_index!
@@ -168,13 +204,17 @@ module RubynCode
168
204
  @output_recovery_count = 0
169
205
  @task_budget_remaining = nil
170
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
171
209
  end
172
210
 
173
211
  def run_iteration(iteration)
174
212
  log_iteration(iteration)
175
213
  @context_manager.advance_turn!
176
214
  compact_if_needed # ensure context is under threshold before LLM call
177
- response = call_llm
215
+ response = call_llm
216
+ return handle_refusal(response) if extract_stop_reason(response) == 'refusal'
217
+
178
218
  tool_calls = extract_tool_calls(response)
179
219
  log_response(response, tool_calls)
180
220
 
@@ -183,6 +223,20 @@ module RubynCode
183
223
  handle_tool_response(response, tool_calls, iteration)
184
224
  end
185
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
+
186
240
  def log_iteration(iteration)
187
241
  RubynCode::Debug.loop_tick(
188
242
  "iteration=#{iteration} messages=#{@conversation.length} " \
@@ -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))
@@ -101,6 +101,9 @@ module RubynCode
101
101
  return unless index&.nodes&.any?
102
102
 
103
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.'
104
107
  rescue StandardError
105
108
  nil
106
109
  end
@@ -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
@@ -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
@@ -40,6 +40,33 @@ module RubynCode
40
40
 
41
41
  # @return [Boolean]
42
42
  def plan_mode? = plan_mode
43
+
44
+ # Restrict the tool set for a single agent invocation. The next
45
+ # `send_message` call inside the block sees only the tools in
46
+ # `allowed`; the override is cleared after the call.
47
+ def with_allowed_tools(allowed = nil, &block)
48
+ apply_loop_override(:allowed_tools_override=, allowed, &block)
49
+ end
50
+
51
+ # Apply a one-shot model override for the duration of a block.
52
+ def with_optional_model(model_name = nil, &block)
53
+ apply_loop_override(:model_override=, model_name, &block)
54
+ end
55
+
56
+ private
57
+
58
+ # Set an override on the agent loop (when one is wired in) for the
59
+ # duration of the block, then clear it. Custom commands without
60
+ # a loop (e.g. test doubles) fall through to a plain yield.
61
+ def apply_loop_override(method, value)
62
+ loop = agent_loop
63
+ return yield unless loop.respond_to?(method)
64
+
65
+ loop.public_send(method, value)
66
+ yield
67
+ ensure
68
+ loop&.public_send(method, nil) if loop.respond_to?(method)
69
+ end
43
70
  end
44
71
  end
45
72
  end
@@ -14,13 +14,24 @@ module RubynCode
14
14
  attr_reader :name
15
15
  # @return [String] one-line description for /help
16
16
  attr_reader :description
17
+ # @return [String, nil] argument hint shown next to the name in /help
18
+ attr_reader :argument_hint
19
+ # @return [Array<String>, nil] tool names the command restricts to
20
+ attr_reader :allowed_tools
21
+ # @return [String, nil] model override for this prompt
22
+ attr_reader :model
17
23
  # @return [String, nil] originating file path
18
24
  attr_reader :source
19
25
 
20
- def initialize(name:, description:, body:, source: nil)
26
+ def initialize(name:, description:, body:, # rubocop:disable Metrics/ParameterLists -- explicit kwargs document the frontmatter surface
27
+ source: nil,
28
+ argument_hint: nil, allowed_tools: nil, model: nil)
21
29
  @name = name
22
30
  @description = description
23
31
  @template = CommandTemplate.new(body)
32
+ @argument_hint = argument_hint
33
+ @allowed_tools = allowed_tools
34
+ @model = model
24
35
  @source = source
25
36
  end
26
37
 
@@ -29,11 +40,42 @@ module RubynCode
29
40
  def hidden? = false
30
41
  def all_names = [command_name].freeze
31
42
 
43
+ # Render the help label, including [hint] when frontmatter provides one.
44
+ # The argument_hint is expected to be the placeholder text already wrapped
45
+ # in [ ] (e.g. "[env]") — we don't add another set of brackets.
46
+ def help_label
47
+ hint = @argument_hint.to_s.strip
48
+ hint.empty? ? description : "#{description} #{hint}"
49
+ end
50
+
51
+ # @return [Boolean] true if the frontmatter restricted the tool set
52
+ def restricts_tools?
53
+ !@allowed_tools.nil? && !@allowed_tools.empty?
54
+ end
55
+
56
+ # @return [Boolean] true if the frontmatter overrode the model
57
+ def overrides_model?
58
+ !@model.to_s.strip.empty?
59
+ end
60
+
32
61
  # @param args [Array<String>]
33
62
  # @param ctx [Commands::Context]
34
63
  # @return [nil]
35
64
  def execute(args, ctx)
36
- ctx.send_message(@template.render(args))
65
+ rendered = @template.render(args)
66
+ if restricts_tools?
67
+ ctx.with_allowed_tools(@allowed_tools) do
68
+ ctx.with_optional_model(@model) do
69
+ ctx.send_message(rendered)
70
+ end
71
+ end
72
+ elsif overrides_model?
73
+ ctx.with_optional_model(@model) do
74
+ ctx.send_message(rendered)
75
+ end
76
+ else
77
+ ctx.send_message(rendered)
78
+ end
37
79
  nil
38
80
  end
39
81
  end
@@ -40,9 +40,18 @@ module RubynCode
40
40
  name = File.basename(path, '.md').strip
41
41
  return nil unless name.match?(NAME)
42
42
 
43
- description, body = parse(File.read(path))
44
- description = "Custom command: /#{name}" if description.to_s.strip.empty?
45
- CustomCommand.new(name: name, description: description, body: body, source: path)
43
+ meta, body = parse(File.read(path))
44
+ description = meta['description'].to_s
45
+ description = "Custom command: /#{name}" if description.strip.empty?
46
+ CustomCommand.new(
47
+ name: name,
48
+ description: description,
49
+ body: body,
50
+ source: path,
51
+ argument_hint: blank_to_nil(stringify(meta['argument_hint'] || meta['argument-hint'])),
52
+ allowed_tools: list_of_tools(meta['allowed_tools'] || meta['allowed-tools']),
53
+ model: blank_to_nil(stringify(meta['model']))
54
+ )
46
55
  rescue StandardError => e
47
56
  RubynCode::Debug.warn("Failed to load custom command #{path}: #{e.message}")
48
57
  nil
@@ -51,13 +60,35 @@ module RubynCode
51
60
  def parse(content)
52
61
  if (match = FRONTMATTER.match(content))
53
62
  frontmatter = YAML.safe_load(match[1]) || {}
54
- [frontmatter['description'].to_s, match[2].to_s.strip]
63
+ [frontmatter, match[2].to_s.strip]
55
64
  else
56
65
  body = content.to_s.strip
57
- [first_line(body), body]
66
+ [{ 'description' => first_line(body) }, body]
58
67
  end
59
68
  end
60
69
 
70
+ def blank_to_nil(value)
71
+ value.to_s.strip.empty? ? nil : value
72
+ end
73
+
74
+ def stringify(value)
75
+ return '' if value.nil?
76
+
77
+ # YAML's flow-style `[env]` is parsed as a single-element array.
78
+ # Stringify the array element directly so users see exactly the
79
+ # characters they typed.
80
+ str = value.is_a?(Array) ? value.join : value
81
+ str.to_s.strip
82
+ end
83
+
84
+ def list_of_tools(value)
85
+ return nil if value.nil?
86
+
87
+ arr = value.is_a?(Array) ? value : value.to_s.split(',')
88
+ cleaned = arr.map { |name| name.to_s.strip }.reject(&:empty?)
89
+ cleaned.empty? ? nil : cleaned
90
+ end
91
+
61
92
  def first_line(body)
62
93
  line = body.lines.first.to_s.strip
63
94
  line.sub(/\A#+\s*/, '')[0, 80]
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # Set or clear the reasoning effort level (output_config.effort).
7
+ #
8
+ # /effort # show current value
9
+ # /effort off # clear back to model default
10
+ # /effort <level> # low | medium | high | xhigh | max
11
+ class Effort < Base
12
+ LEVELS = %w[low medium high xhigh max].freeze
13
+
14
+ def self.command_name = '/effort'
15
+ def self.description = 'Set reasoning effort (low/medium/high/xhigh/max)'
16
+
17
+ def execute(args, ctx)
18
+ arg = args.first
19
+
20
+ current = ctx.llm_client.respond_to?(:effort) ? ctx.llm_client.effort : nil
21
+
22
+ case arg
23
+ when nil
24
+ show_status(current, ctx)
25
+ when 'off'
26
+ ctx.llm_client.effort = nil
27
+ ctx.renderer.info('Reasoning effort cleared — using model default 🎚️')
28
+ when *LEVELS
29
+ ctx.llm_client.effort = arg
30
+ ctx.renderer.info("Reasoning effort set to #{arg} 🎚️")
31
+ else
32
+ ctx.renderer.warning("Usage: /effort [off|#{LEVELS.join('|')}]. Got: #{arg}")
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def show_status(current, ctx)
39
+ state = current || 'not set (model default: high on 4.6+)'
40
+ ctx.renderer.info("Reasoning effort: #{state}")
41
+ ctx.renderer.info("Usage: /effort <level> levels: #{LEVELS.join(', ')} (or /effort off)")
42
+ ctx.renderer.info('Supported on Claude 4.6+ models; xhigh requires Opus 4.7+ / Sonnet 5 / Fable 5.')
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end