rubyn-code 0.2.2 → 0.4.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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +151 -5
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  5. data/lib/rubyn_code/agent/conversation.rb +84 -56
  6. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
  7. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  8. data/lib/rubyn_code/agent/llm_caller.rb +157 -0
  9. data/lib/rubyn_code/agent/loop.rb +182 -683
  10. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  11. data/lib/rubyn_code/agent/prompts.rb +109 -0
  12. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  13. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  14. data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
  15. data/lib/rubyn_code/agent/tool_processor.rb +178 -0
  16. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  17. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  18. data/lib/rubyn_code/auth/oauth.rb +80 -64
  19. data/lib/rubyn_code/auth/server.rb +21 -24
  20. data/lib/rubyn_code/auth/token_store.rb +80 -52
  21. data/lib/rubyn_code/autonomous/daemon.rb +146 -32
  22. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
  23. data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
  24. data/lib/rubyn_code/background/worker.rb +64 -76
  25. data/lib/rubyn_code/cli/app.rb +159 -114
  26. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  27. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  28. data/lib/rubyn_code/cli/commands/model.rb +105 -18
  29. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  30. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  31. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  32. data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
  33. data/lib/rubyn_code/cli/first_run.rb +159 -0
  34. data/lib/rubyn_code/cli/renderer.rb +109 -60
  35. data/lib/rubyn_code/cli/repl.rb +48 -374
  36. data/lib/rubyn_code/cli/repl_commands.rb +177 -0
  37. data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
  38. data/lib/rubyn_code/cli/repl_setup.rb +181 -0
  39. data/lib/rubyn_code/cli/setup.rb +6 -2
  40. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  41. data/lib/rubyn_code/cli/version_check.rb +28 -11
  42. data/lib/rubyn_code/config/defaults.rb +11 -0
  43. data/lib/rubyn_code/config/project_profile.rb +185 -0
  44. data/lib/rubyn_code/config/schema.json +49 -0
  45. data/lib/rubyn_code/config/settings.rb +103 -1
  46. data/lib/rubyn_code/config/validator.rb +63 -0
  47. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  48. data/lib/rubyn_code/context/context_budget.rb +182 -0
  49. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  50. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  51. data/lib/rubyn_code/context/manager.rb +44 -8
  52. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  53. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  54. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  55. data/lib/rubyn_code/db/connection.rb +31 -26
  56. data/lib/rubyn_code/db/migrator.rb +44 -28
  57. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  58. data/lib/rubyn_code/hooks/registry.rb +4 -0
  59. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  60. data/lib/rubyn_code/ide/client.rb +110 -0
  61. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  62. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  63. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  64. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  65. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  66. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  67. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  69. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  70. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  71. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  72. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  73. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  74. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  75. data/lib/rubyn_code/ide/handlers.rb +76 -0
  76. data/lib/rubyn_code/ide/protocol.rb +111 -0
  77. data/lib/rubyn_code/ide/server.rb +186 -0
  78. data/lib/rubyn_code/index/codebase_index.rb +311 -0
  79. data/lib/rubyn_code/learning/extractor.rb +65 -82
  80. data/lib/rubyn_code/learning/injector.rb +22 -23
  81. data/lib/rubyn_code/learning/instinct.rb +71 -42
  82. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  83. data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
  84. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  85. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  86. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  87. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  88. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  89. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
  90. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  91. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  92. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  93. data/lib/rubyn_code/llm/client.rb +75 -247
  94. data/lib/rubyn_code/llm/model_router.rb +237 -0
  95. data/lib/rubyn_code/llm/streaming.rb +4 -227
  96. data/lib/rubyn_code/mcp/client.rb +1 -1
  97. data/lib/rubyn_code/mcp/config.rb +10 -12
  98. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  99. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  100. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  101. data/lib/rubyn_code/memory/search.rb +1 -0
  102. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  103. data/lib/rubyn_code/memory/store.rb +42 -55
  104. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  105. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  106. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  107. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  108. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  109. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  110. data/lib/rubyn_code/output/formatter.rb +11 -11
  111. data/lib/rubyn_code/permissions/policy.rb +11 -13
  112. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  113. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  114. data/lib/rubyn_code/self_test.rb +315 -0
  115. data/lib/rubyn_code/skills/catalog.rb +66 -0
  116. data/lib/rubyn_code/skills/document.rb +33 -29
  117. data/lib/rubyn_code/skills/loader.rb +43 -0
  118. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  119. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  120. data/lib/rubyn_code/tasks/dag.rb +25 -24
  121. data/lib/rubyn_code/tasks/models.rb +1 -0
  122. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  123. data/lib/rubyn_code/tools/background_run.rb +2 -1
  124. data/lib/rubyn_code/tools/base.rb +39 -32
  125. data/lib/rubyn_code/tools/bash.rb +7 -1
  126. data/lib/rubyn_code/tools/edit_file.rb +130 -17
  127. data/lib/rubyn_code/tools/executor.rb +130 -25
  128. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  129. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  130. data/lib/rubyn_code/tools/git_log.rb +12 -10
  131. data/lib/rubyn_code/tools/glob.rb +29 -7
  132. data/lib/rubyn_code/tools/grep.rb +8 -1
  133. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  134. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  135. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  136. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  137. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  138. data/lib/rubyn_code/tools/output_compressor.rb +190 -0
  139. data/lib/rubyn_code/tools/read_file.rb +17 -6
  140. data/lib/rubyn_code/tools/registry.rb +11 -0
  141. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  142. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  143. data/lib/rubyn_code/tools/schema.rb +4 -10
  144. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  145. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  146. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  147. data/lib/rubyn_code/tools/task.rb +17 -17
  148. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  149. data/lib/rubyn_code/tools/web_search.rb +66 -48
  150. data/lib/rubyn_code/tools/write_file.rb +76 -1
  151. data/lib/rubyn_code/version.rb +1 -1
  152. data/lib/rubyn_code.rb +62 -1
  153. data/skills/rubyn_self_test.md +133 -0
  154. metadata +83 -1
@@ -15,10 +15,10 @@ module RubynCode
15
15
  ALWAYS_ALLOW = %w[
16
16
  read_file glob grep git_status git_diff git_log
17
17
  memory_search memory_write load_skill compact
18
- task web_search web_fetch
18
+ task web_search web_fetch ask_user
19
19
  ].to_set.freeze
20
20
 
21
- def self.check(tool_name:, tool_input:, tier:, deny_list:)
21
+ def self.check(tool_name:, tier:, deny_list:, tool_input: nil) # rubocop:disable Lint/UnusedMethodArgument
22
22
  return :deny if deny_list.blocks?(tool_name)
23
23
  return :allow if ALWAYS_ALLOW.include?(tool_name)
24
24
 
@@ -26,17 +26,15 @@ module RubynCode
26
26
 
27
27
  return :ask if risk == :destructive
28
28
 
29
+ resolve_by_tier(tier, risk)
30
+ end
31
+
32
+ def self.resolve_by_tier(tier, risk)
29
33
  case tier
30
- when Tier::ASK_ALWAYS
31
- :ask
32
- when Tier::ALLOW_READ
33
- risk == :read ? :allow : :ask
34
- when Tier::AUTONOMOUS
35
- risk == :external ? :ask : :allow
36
- when Tier::UNRESTRICTED
37
- :allow
38
- else
39
- :ask
34
+ when Tier::ASK_ALWAYS, nil then :ask
35
+ when Tier::ALLOW_READ then risk == :read ? :allow : :ask
36
+ when Tier::AUTONOMOUS then risk == :external ? :ask : :allow
37
+ when Tier::UNRESTRICTED then :allow
40
38
  end
41
39
  end
42
40
 
@@ -53,7 +51,7 @@ module RubynCode
53
51
  :unknown
54
52
  end
55
53
 
56
- private_class_method :resolve_risk
54
+ private_class_method :resolve_risk, :resolve_by_tier
57
55
  end
58
56
  end
59
57
  end
@@ -33,21 +33,20 @@ module RubynCode
33
33
  # @param tool_input [Hash]
34
34
  # @return [Boolean] true if the user approved
35
35
  def self.confirm_destructive(tool_name, tool_input)
36
- prompt = build_prompt
37
36
  pastel = Pastel.new
37
+ display_destructive_warning(pastel, tool_name, tool_input)
38
+
39
+ answer = build_prompt.ask(pastel.red.bold('Type "yes" to confirm this destructive action:'))
40
+ answer&.strip&.downcase == 'yes'
41
+ rescue TTY::Prompt::Reader::InputInterrupt
42
+ false
43
+ end
38
44
 
45
+ def self.display_destructive_warning(pastel, tool_name, tool_input)
39
46
  $stdout.puts pastel.red.bold('WARNING: Destructive operation requested')
40
47
  $stdout.puts pastel.red('=' * 50)
41
48
  display_tool_summary(pastel, tool_name, tool_input)
42
49
  $stdout.puts pastel.red('=' * 50)
43
-
44
- answer = prompt.ask(
45
- pastel.red.bold('Type "yes" to confirm this destructive action:')
46
- )
47
-
48
- answer&.strip&.downcase == 'yes'
49
- rescue TTY::Prompt::Reader::InputInterrupt
50
- false
51
50
  end
52
51
 
53
52
  # @api private
@@ -22,39 +22,44 @@ module RubynCode
22
22
  # @return [Symbol] :approved or :rejected
23
23
  def request(plan_text, prompt: nil)
24
24
  pastel = Pastel.new
25
- tty = build_prompt
25
+ display_plan(pastel, plan_text, prompt)
26
26
 
27
+ approved = build_prompt.yes?(
28
+ pastel.yellow.bold('Do you approve this plan?'),
29
+ default: false
30
+ )
31
+
32
+ approved ? approve(pastel) : reject(pastel)
33
+ rescue TTY::Reader::InputInterrupt
34
+ $stdout.puts pastel.red("\nPlan rejected (interrupted).")
35
+ REJECTED
36
+ end
37
+
38
+ private
39
+
40
+ def display_plan(pastel, plan_text, prompt)
27
41
  $stdout.puts
28
42
  $stdout.puts pastel.cyan.bold('Proposed Plan')
29
43
  $stdout.puts pastel.cyan('=' * 60)
30
44
  $stdout.puts plan_text
31
45
  $stdout.puts pastel.cyan('=' * 60)
32
46
  $stdout.puts
47
+ return unless prompt
33
48
 
34
- if prompt
35
- $stdout.puts pastel.yellow(prompt)
36
- $stdout.puts
37
- end
49
+ $stdout.puts pastel.yellow(prompt)
50
+ $stdout.puts
51
+ end
38
52
 
39
- approved = tty.yes?(
40
- pastel.yellow.bold('Do you approve this plan?'),
41
- default: false
42
- )
53
+ def approve(pastel)
54
+ $stdout.puts pastel.green('Plan approved.')
55
+ APPROVED
56
+ end
43
57
 
44
- if approved
45
- $stdout.puts pastel.green('Plan approved.')
46
- APPROVED
47
- else
48
- $stdout.puts pastel.red('Plan rejected.')
49
- REJECTED
50
- end
51
- rescue TTY::Reader::InputInterrupt
52
- $stdout.puts pastel.red("\nPlan rejected (interrupted).")
58
+ def reject(pastel)
59
+ $stdout.puts pastel.red('Plan rejected.')
53
60
  REJECTED
54
61
  end
55
62
 
56
- private
57
-
58
63
  # Builds a TTY::Prompt instance configured for non-destructive interrupt handling.
59
64
  #
60
65
  # @return [TTY::Prompt]
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module RubynCode
6
+ # Programmatic smoke test that exercises every major subsystem.
7
+ # Mirrors the checks in skills/rubyn_self_test.md but runs without
8
+ # the REPL or an LLM — suitable for CI and rake tasks.
9
+ #
10
+ # rubocop:disable Metrics/ClassLength -- intentionally comprehensive
11
+ class SelfTest
12
+ Result = Data.define(:name, :passed, :detail)
13
+
14
+ attr_reader :results, :project_root
15
+
16
+ def initialize(project_root: Dir.pwd)
17
+ @project_root = project_root
18
+ @results = []
19
+ end
20
+
21
+ def run! # rubocop:disable Naming/PredicateMethod -- returns bool but primary purpose is side effects
22
+ @results = []
23
+
24
+ check_file_operations
25
+ check_search
26
+ check_bash
27
+ check_git
28
+ check_specs
29
+ check_compressor_strategies
30
+ check_skills
31
+ check_config
32
+ check_codebase_index
33
+ check_slash_commands
34
+ check_architecture
35
+
36
+ print_scorecard
37
+ results.all?(&:passed)
38
+ end
39
+
40
+ private
41
+
42
+ # ── 1. File Operations ──────────────────────────────────────────
43
+
44
+ def check_file_operations
45
+ check_file_read
46
+ check_file_write_edit_cleanup
47
+ rescue StandardError => e
48
+ record('File operations', false, e.message)
49
+ end
50
+
51
+ def check_file_read
52
+ content = File.read(File.join(project_root, 'lib/rubyn_code/version.rb'))
53
+ record('File read (version.rb)', content.include?('VERSION ='))
54
+ end
55
+
56
+ def check_file_write_edit_cleanup # rubocop:disable Metrics/AbcSize -- sequential file ops
57
+ tmp = File.join(project_root, '.rubyn-code/self_test_tmp.rb')
58
+ FileUtils.mkdir_p(File.dirname(tmp))
59
+
60
+ File.write(tmp, '# self-test')
61
+ record('File write (tmp)', File.exist?(tmp))
62
+
63
+ File.write(tmp, File.read(tmp).sub('# self-test', '# self-test passed'))
64
+ record('File edit (tmp)', File.read(tmp).include?('# self-test passed'))
65
+
66
+ File.delete(tmp)
67
+ record('File cleanup (tmp)', !File.exist?(tmp))
68
+ end
69
+
70
+ # ── 2. Search ───────────────────────────────────────────────────
71
+
72
+ def check_search
73
+ rb_files = Dir.glob(File.join(project_root, 'lib/**/*.rb'))
74
+ record('Glob lib/**/*.rb', rb_files.size >= 50, "#{rb_files.size} files")
75
+
76
+ base_classes = rb_files.count { |f| File.read(f).match?(/class.*Base/) }
77
+ record('Grep class.*Base', base_classes >= 3, "#{base_classes} matches")
78
+ rescue StandardError => e
79
+ record('Search', false, e.message)
80
+ end
81
+
82
+ # ── 3. Bash ─────────────────────────────────────────────────────
83
+
84
+ def check_bash
85
+ ruby_v = `ruby --version 2>&1`.strip
86
+ record('Bash: ruby --version', ruby_v.include?('ruby'), ruby_v)
87
+
88
+ rubocop_v = `bundle exec rubocop --version 2>&1`.strip
89
+ record('Bash: rubocop --version', rubocop_v.match?(/\d+\.\d+/), rubocop_v)
90
+ rescue StandardError => e
91
+ record('Bash', false, e.message)
92
+ end
93
+
94
+ # ── 4. Git ──────────────────────────────────────────────────────
95
+
96
+ def check_git
97
+ run_cmd('git status --short')
98
+ record('Git status', true)
99
+
100
+ log = run_cmd('git log --oneline -3')
101
+ record('Git log', log.match?(/^[0-9a-f]+/), log.lines.first&.strip)
102
+
103
+ run_cmd('git diff --stat')
104
+ record('Git diff', true)
105
+ rescue StandardError => e
106
+ record('Git', false, e.message)
107
+ end
108
+
109
+ # ── 5. Specs ────────────────────────────────────────────────────
110
+
111
+ def check_specs
112
+ %w[
113
+ spec/rubyn_code/tools/output_compressor_spec.rb
114
+ spec/rubyn_code/llm/model_router_spec.rb
115
+ ].each { |spec| run_single_spec(spec) }
116
+ rescue StandardError => e
117
+ record('Specs', false, e.message)
118
+ end
119
+
120
+ def run_single_spec(spec)
121
+ path = File.join(project_root, spec)
122
+ unless File.exist?(path)
123
+ record("RSpec: #{File.basename(spec)}", false, 'file not found')
124
+ return
125
+ end
126
+ output = run_cmd("bundle exec rspec #{path} --format progress 2>&1")
127
+ record("RSpec: #{File.basename(spec)}", output.include?('0 failures'))
128
+ end
129
+
130
+ # ── 6. Output Compressor ────────────────────────────────────────
131
+
132
+ def check_compressor_strategies
133
+ compressor = Tools::OutputCompressor.new
134
+ verified = 0
135
+
136
+ verified += verify_head_tail(compressor)
137
+ verified += verify_spec_summary(compressor)
138
+ verified += verify_top_matches(compressor)
139
+ verified += verify_tree_collapse(compressor)
140
+ verified += verify_diff_hunks(compressor)
141
+
142
+ record('Compression strategies verified', verified >= 3, "#{verified}/5 active")
143
+ rescue StandardError => e
144
+ record('Output compressor', false, e.message)
145
+ end
146
+
147
+ def verify_head_tail(compressor)
148
+ big = (1..5000).to_a.join("\n")
149
+ compressed = compressor.compress('bash', big)
150
+ pass = compressed.length < big.length
151
+ record('Compressor: head_tail', pass)
152
+ pass ? 1 : 0
153
+ end
154
+
155
+ def verify_spec_summary(compressor)
156
+ spec_out = run_cmd(
157
+ 'bundle exec rspec spec/rubyn_code/tools/base_spec.rb --format documentation 2>&1'
158
+ )
159
+ compressed = compressor.compress('run_specs', spec_out)
160
+ pass = compressed.length < spec_out.length || compressed.include?('0 failures')
161
+ record('Compressor: spec_summary', pass)
162
+ pass ? 1 : 0
163
+ end
164
+
165
+ def verify_top_matches(compressor)
166
+ grep_out = rb_files_with_def.join("\n")
167
+ compressed = compressor.compress('grep', grep_out)
168
+ pass = compressed.length <= grep_out.length
169
+ record('Compressor: top_matches', pass)
170
+ pass ? 1 : 0
171
+ end
172
+
173
+ def verify_tree_collapse(compressor)
174
+ all_rb = Dir.glob(File.join(project_root, '**/*.rb')).join("\n")
175
+ compressed = compressor.compress('glob', all_rb)
176
+ pass = compressed.length <= all_rb.length
177
+ record('Compressor: tree_collapse', pass)
178
+ pass ? 1 : 0
179
+ end
180
+
181
+ def verify_diff_hunks(compressor)
182
+ diff = run_cmd('git log --oneline -1 --format=%H | xargs -I{} git diff {}~5..{} 2>/dev/null')
183
+ if diff.strip.empty?
184
+ record('Compressor: diff_hunks', true, 'SKIP — diff too small')
185
+ return 0
186
+ end
187
+ pass = compressor.compress('git_diff', diff).length <= diff.length
188
+ record('Compressor: diff_hunks', pass)
189
+ pass ? 1 : 0
190
+ end
191
+
192
+ # ── 7. Skills ───────────────────────────────────────────────────
193
+
194
+ def check_skills
195
+ catalog = Skills::Catalog.new(project_root)
196
+ skills = catalog.available
197
+ record('Skills catalog', skills.size >= 10, "#{skills.size} skills")
198
+ rescue StandardError => e
199
+ record('Skills', false, e.message)
200
+ end
201
+
202
+ # ── 8. Config ───────────────────────────────────────────────────
203
+
204
+ def check_config
205
+ config_path = File.expand_path('~/.rubyn-code/config.yml')
206
+ if File.exist?(config_path)
207
+ record('Config (config.yml)', File.read(config_path).include?('provider'))
208
+ else
209
+ record('Config (config.yml)', false, 'not found')
210
+ end
211
+
212
+ profile = File.join(project_root, '.rubyn-code/project_profile.yml')
213
+ record('Config (project_profile)', File.exist?(profile),
214
+ File.exist?(profile) ? 'exists' : 'SKIP — first session')
215
+ rescue StandardError => e
216
+ record('Config', false, e.message)
217
+ end
218
+
219
+ # ── 9. Codebase Index ───────────────────────────────────────────
220
+
221
+ def check_codebase_index
222
+ path = File.join(project_root, '.rubyn-code/codebase_index.json')
223
+ record('Codebase index', File.exist?(path),
224
+ File.exist?(path) ? 'exists' : 'SKIP — first session')
225
+ rescue StandardError => e
226
+ record('Codebase index', false, e.message)
227
+ end
228
+
229
+ # ── 10. Slash Commands ──────────────────────────────────────────
230
+
231
+ def check_slash_commands
232
+ cmd_dir = File.join(project_root, 'lib/rubyn_code/cli/commands')
233
+ infra = %w[base.rb context.rb registry.rb]
234
+ cmds = Dir.glob(File.join(cmd_dir, '*.rb')).reject { |f| infra.include?(File.basename(f)) }
235
+ record('Slash commands', cmds.size >= 15, "#{cmds.size} commands")
236
+ rescue StandardError => e
237
+ record('Slash commands', false, e.message)
238
+ end
239
+
240
+ # ── 11. Architecture ────────────────────────────────────────────
241
+
242
+ def check_architecture
243
+ check_autoloads
244
+ check_layer_dirs
245
+ check_core_modules
246
+ rescue StandardError => e
247
+ record('Architecture', false, e.message)
248
+ end
249
+
250
+ def check_autoloads
251
+ content = File.read(File.join(project_root, 'lib/rubyn_code.rb'))
252
+ autoloads = content.scan('autoload').size
253
+ record('Autoload entries', autoloads >= 40, "#{autoloads} entries")
254
+ end
255
+
256
+ def check_layer_dirs
257
+ dirs = Dir.glob(File.join(project_root, 'lib/rubyn_code/*/'))
258
+ record('Layer directories', dirs.size >= 14, "#{dirs.size} dirs")
259
+ end
260
+
261
+ def check_core_modules
262
+ content = File.read(File.join(project_root, 'lib/rubyn_code.rb'))
263
+ core = %w[Agent Tools Context Skills Memory Observability Learning]
264
+ found = core.select { |m| content.include?("module #{m}") }
265
+ record('Core modules', found.size == core.size, "#{found.size}/#{core.size}")
266
+ end
267
+
268
+ # ── Helpers ─────────────────────────────────────────────────────
269
+
270
+ def record(name, passed, detail = nil)
271
+ @results << Result.new(name: name, passed: passed, detail: detail)
272
+ end
273
+
274
+ def run_cmd(cmd)
275
+ `cd #{project_root} && #{cmd} 2>&1`.strip
276
+ end
277
+
278
+ def rb_files_with_def
279
+ Dir.glob(File.join(project_root, 'lib/**/*.rb')).flat_map do |f|
280
+ File.readlines(f).select { |l| l.include?('def ') }.map { |l| "#{f}:#{l.strip}" }
281
+ end
282
+ end
283
+
284
+ def print_scorecard
285
+ puts
286
+ puts 'Rubyn Self-Test Results'
287
+ puts '=' * 50
288
+ results.each_with_index { |r, i| print_result(r, i + 1) }
289
+ print_summary
290
+ end
291
+
292
+ def print_result(result, num)
293
+ icon = result.passed ? "\e[32m✅\e[0m" : "\e[31m❌\e[0m"
294
+ suffix = result.detail ? " — #{result.detail}" : ''
295
+ puts format(' %2<num>d. %<icon>s %<name>s%<suffix>s',
296
+ num: num, icon: icon, name: result.name, suffix: suffix)
297
+ end
298
+
299
+ def print_summary
300
+ passed = results.count(&:passed)
301
+ total = results.size
302
+ pct = total.positive? ? (passed * 100.0 / total).round : 0
303
+ failed = total - passed
304
+
305
+ puts '=' * 50
306
+ if failed.zero?
307
+ puts "\e[32mScore: #{passed}/#{total} (#{pct}%) — All systems go!\e[0m"
308
+ else
309
+ puts "\e[33mScore: #{passed}/#{total} (#{pct}%) — #{failed} failures\e[0m"
310
+ end
311
+ puts
312
+ end
313
+ end
314
+ # rubocop:enable Metrics/ClassLength
315
+ end
@@ -24,11 +24,53 @@ module RubynCode
24
24
  @index
25
25
  end
26
26
 
27
+ def list
28
+ available.map { |e| e[:name] }
29
+ end
30
+
27
31
  def find(name)
28
32
  entry = available.find { |e| e[:name] == name.to_s }
29
33
  entry&.fetch(:path)
30
34
  end
31
35
 
36
+ # Search skill content — matches against names, descriptions, and tags.
37
+ # Returns matching entries sorted by relevance (number of field matches).
38
+ #
39
+ # @param term [String] search term (case-insensitive)
40
+ # @return [Array<Hash>] matching entries with :name, :description, :path, :relevance
41
+ def search(term)
42
+ pattern = /#{Regexp.escape(term)}/i
43
+ matches = available.filter_map do |entry|
44
+ relevance = compute_relevance(entry, pattern)
45
+ next if relevance.zero?
46
+
47
+ entry.merge(relevance: relevance)
48
+ end
49
+ matches.sort_by { |e| -e[:relevance] }
50
+ end
51
+
52
+ # Filter skills by category (subdirectory name).
53
+ # Skills are organized in subdirectories under each skills_dir.
54
+ #
55
+ # @param category [String] category/directory name (e.g. "rails", "testing")
56
+ # @return [Array<Hash>] matching entries
57
+ def by_category(category)
58
+ normalized = category.to_s.downcase
59
+ available.select do |entry|
60
+ path_category(entry[:path]).downcase == normalized
61
+ end
62
+ end
63
+
64
+ # Return the list of unique categories derived from skill file paths.
65
+ #
66
+ # @return [Array<String>] sorted category names
67
+ def categories
68
+ available.map { |e| path_category(e[:path]) }
69
+ .reject(&:empty?)
70
+ .uniq
71
+ .sort
72
+ end
73
+
32
74
  private
33
75
 
34
76
  def build_index
@@ -60,11 +102,35 @@ module RubynCode
60
102
  {
61
103
  name: name,
62
104
  description: doc.description,
105
+ tags: doc.tags,
63
106
  path: File.expand_path(path)
64
107
  }
65
108
  rescue StandardError
66
109
  nil
67
110
  end
111
+
112
+ def compute_relevance(entry, pattern)
113
+ score = 0
114
+ score += 3 if entry[:name].to_s.match?(pattern)
115
+ score += 2 if entry[:description].to_s.match?(pattern)
116
+ Array(entry[:tags]).each { |tag| score += 1 if tag.to_s.match?(pattern) }
117
+ score
118
+ end
119
+
120
+ # Derive a category from the skill file path.
121
+ # The category is the immediate parent directory name relative to one of
122
+ # the skills_dirs. Skills at the top level of a skills_dir have no category.
123
+ def path_category(path)
124
+ skills_dirs.each do |dir|
125
+ expanded = File.expand_path(dir)
126
+ next unless path.start_with?(expanded)
127
+
128
+ relative = path.delete_prefix("#{expanded}/")
129
+ parts = relative.split('/')
130
+ return parts.size > 1 ? parts.first : ''
131
+ end
132
+ ''
133
+ end
68
134
  end
69
135
  end
70
136
  end
@@ -19,30 +19,30 @@ module RubynCode
19
19
  class << self
20
20
  def parse(content, filename: nil)
21
21
  match = FRONTMATTER_PATTERN.match(content)
22
+ match ? parse_with_frontmatter(match) : parse_without_frontmatter(content, filename)
23
+ end
22
24
 
23
- if match
24
- frontmatter = YAML.safe_load(match[1], permitted_classes: [Symbol]) || {}
25
- body = match[2].to_s.strip
25
+ def parse_with_frontmatter(match)
26
+ frontmatter = YAML.safe_load(match[1], permitted_classes: [Symbol]) || {}
27
+ new(
28
+ name: frontmatter['name'].to_s,
29
+ description: frontmatter['description'].to_s,
30
+ tags: Array(frontmatter['tags']),
31
+ body: match[2].to_s.strip
32
+ )
33
+ end
26
34
 
27
- new(
28
- name: frontmatter['name'].to_s,
29
- description: frontmatter['description'].to_s,
30
- tags: Array(frontmatter['tags']),
31
- body: body
32
- )
33
- else
34
- body = content.to_s.strip
35
- title = extract_title(body)
36
- derived_name = filename ? File.basename(filename, '.*').tr('_', '-') : title_to_name(title)
37
- tags = derive_tags(derived_name, body)
35
+ def parse_without_frontmatter(content, filename)
36
+ body = content.to_s.strip
37
+ title = extract_title(body)
38
+ derived_name = filename ? File.basename(filename, '.*').tr('_', '-') : title_to_name(title)
38
39
 
39
- new(
40
- name: derived_name,
41
- description: title,
42
- tags: tags,
43
- body: body
44
- )
45
- end
40
+ new(
41
+ name: derived_name,
42
+ description: title,
43
+ tags: derive_tags(derived_name, body),
44
+ body: body
45
+ )
46
46
  end
47
47
 
48
48
  def parse_file(path)
@@ -53,6 +53,15 @@ module RubynCode
53
53
  parse(content, filename: path)
54
54
  end
55
55
 
56
+ TAG_RULES = [
57
+ ['ruby', /\bruby\b/i],
58
+ ['rails', /\brails\b/i],
59
+ ['rspec', /\brspec\b/i],
60
+ ['testing', /\b(?:test|spec|minitest)\b/i],
61
+ ['patterns', /\b(?:pattern|design|solid)\b/i],
62
+ ['refactoring', /\brefactor/i]
63
+ ].freeze
64
+
56
65
  private
57
66
 
58
67
  def extract_title(body)
@@ -65,14 +74,9 @@ module RubynCode
65
74
  end
66
75
 
67
76
  def derive_tags(name, body)
68
- tags = []
69
- tags << 'ruby' if body.match?(/\bruby\b/i) || name.include?('ruby')
70
- tags << 'rails' if body.match?(/\brails\b/i) || name.include?('rails')
71
- tags << 'rspec' if body.match?(/\brspec\b/i) || name.include?('rspec')
72
- tags << 'testing' if body.match?(/\b(test|spec|minitest)\b/i)
73
- tags << 'patterns' if body.match?(/\b(pattern|design|solid)\b/i)
74
- tags << 'refactoring' if body.match?(/\brefactor/i)
75
- tags.uniq
77
+ TAG_RULES.each_with_object([]) do |(tag, pattern), tags|
78
+ tags << tag if body.match?(pattern) || name.include?(tag)
79
+ end.uniq
76
80
  end
77
81
  end
78
82
  end
@@ -33,6 +33,31 @@ module RubynCode
33
33
  catalog.descriptions
34
34
  end
35
35
 
36
+ # Suggest skills based on what the codebase index reveals about the project.
37
+ #
38
+ # Inspects class names, parent classes, and file paths in the index to
39
+ # detect common Rails patterns (Devise, ActionMailer, ActiveJob, etc.)
40
+ # and returns matching skill names.
41
+ #
42
+ # @param codebase_index [RubynCode::Index::CodebaseIndex, nil]
43
+ # @param project_profile [Object, nil] reserved for future profile-based hints
44
+ # @return [Array<String>] suggested skill names (not loaded automatically)
45
+ def suggest_skills(codebase_index: nil, project_profile: nil) # rubocop:disable Lint/UnusedMethodArgument, Metrics/CyclomaticComplexity -- project_profile reserved for future use
46
+ return [] unless codebase_index
47
+
48
+ suggestions = []
49
+ node_names = codebase_index.nodes.map { |n| n['name'].to_s }
50
+ node_files = codebase_index.nodes.map { |n| n['file'].to_s }
51
+
52
+ suggestions << 'authentication' if detect_devise?(node_names, node_files)
53
+ suggestions << 'mailer' if detect_action_mailer?(node_names, node_files)
54
+ suggestions << 'background-job' if detect_active_job?(node_names, node_files)
55
+
56
+ suggestions
57
+ rescue StandardError
58
+ []
59
+ end
60
+
36
61
  private
37
62
 
38
63
  def format_skill(doc)
@@ -50,6 +75,24 @@ module RubynCode
50
75
  .gsub('>', '&gt;')
51
76
  .gsub('"', '&quot;')
52
77
  end
78
+
79
+ # Devise detection: look for Devise-related class names or config files.
80
+ def detect_devise?(node_names, node_files)
81
+ node_names.any? { |n| n.match?(/\bDevise\b/i) } ||
82
+ node_files.any? { |f| f.include?('devise') }
83
+ end
84
+
85
+ # ActionMailer detection: look for mailer classes or mailer directory.
86
+ def detect_action_mailer?(node_names, node_files)
87
+ node_names.any? { |n| n.match?(/Mailer\b/) } ||
88
+ node_files.any? { |f| f.include?('app/mailers/') }
89
+ end
90
+
91
+ # ActiveJob detection: look for job classes or jobs directory.
92
+ def detect_active_job?(node_names, node_files)
93
+ node_names.any? { |n| n.match?(/Job\b/) } ||
94
+ node_files.any? { |f| f.include?('app/jobs/') }
95
+ end
53
96
  end
54
97
  end
55
98
  end