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.
- checksums.yaml +4 -4
- data/README.md +151 -5
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +84 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +157 -0
- data/lib/rubyn_code/agent/loop.rb +182 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
- data/lib/rubyn_code/agent/tool_processor.rb +178 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +80 -52
- data/lib/rubyn_code/autonomous/daemon.rb +146 -32
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
- data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +159 -114
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +105 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/commands/provider.rb +123 -0
- data/lib/rubyn_code/cli/commands/skill.rb +52 -3
- data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +48 -374
- data/lib/rubyn_code/cli/repl_commands.rb +177 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
- data/lib/rubyn_code/cli/repl_setup.rb +181 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +11 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +103 -1
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +182 -0
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +44 -8
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/hooks/registry.rb +4 -0
- data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
- data/lib/rubyn_code/ide/client.rb +110 -0
- data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
- data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
- data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
- data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
- data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
- data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
- data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
- data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
- data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
- data/lib/rubyn_code/ide/handlers.rb +76 -0
- data/lib/rubyn_code/ide/protocol.rb +111 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +311 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +75 -247
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +10 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +39 -32
- data/lib/rubyn_code/tools/bash.rb +7 -1
- data/lib/rubyn_code/tools/edit_file.rb +130 -17
- data/lib/rubyn_code/tools/executor.rb +130 -25
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +29 -7
- data/lib/rubyn_code/tools/grep.rb +8 -1
- data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +190 -0
- data/lib/rubyn_code/tools/read_file.rb +17 -6
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +76 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +62 -1
- data/skills/rubyn_self_test.md +133 -0
- 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:,
|
|
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
|
-
|
|
32
|
-
when Tier::
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
end
|
|
49
|
+
$stdout.puts pastel.yellow(prompt)
|
|
50
|
+
$stdout.puts
|
|
51
|
+
end
|
|
38
52
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
def approve(pastel)
|
|
54
|
+
$stdout.puts pastel.green('Plan approved.')
|
|
55
|
+
APPROVED
|
|
56
|
+
end
|
|
43
57
|
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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('>', '>')
|
|
51
76
|
.gsub('"', '"')
|
|
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
|