rubyn-code 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -3
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +89 -3
  5. data/lib/rubyn_code/agent/llm_caller.rb +2 -2
  6. data/lib/rubyn_code/agent/loop.rb +49 -9
  7. data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
  8. data/lib/rubyn_code/agent/tool_processor.rb +3 -1
  9. data/lib/rubyn_code/auth/oauth.rb +1 -1
  10. data/lib/rubyn_code/auth/token_store.rb +49 -4
  11. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  12. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  13. data/lib/rubyn_code/chisel/debt.rb +65 -0
  14. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  15. data/lib/rubyn_code/chisel.rb +127 -0
  16. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  17. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  18. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  19. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  20. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  21. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  23. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  24. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  25. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  26. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  27. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  28. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  29. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  30. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  31. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  32. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  33. data/lib/rubyn_code/cli/first_run.rb +1 -1
  34. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  35. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  36. data/lib/rubyn_code/cli/renderer.rb +3 -2
  37. data/lib/rubyn_code/cli/repl.rb +37 -14
  38. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  39. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  40. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  41. data/lib/rubyn_code/cli/version_check.rb +10 -3
  42. data/lib/rubyn_code/config/defaults.rb +13 -1
  43. data/lib/rubyn_code/config/schema.json +4 -0
  44. data/lib/rubyn_code/config/settings.rb +17 -2
  45. data/lib/rubyn_code/context/manager.rb +29 -12
  46. data/lib/rubyn_code/debug.rb +11 -5
  47. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  48. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  49. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  50. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  51. data/lib/rubyn_code/hooks/response.rb +83 -0
  52. data/lib/rubyn_code/hooks/runner.rb +61 -3
  53. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  54. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  55. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  56. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  57. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  58. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  59. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  60. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  61. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  62. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  63. data/lib/rubyn_code/learning/porter.rb +129 -0
  64. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  65. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  66. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  67. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  68. data/lib/rubyn_code/llm/model_router.rb +2 -2
  69. data/lib/rubyn_code/mcp/client.rb +59 -0
  70. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  71. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  72. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  73. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  74. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  75. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  76. data/lib/rubyn_code/memory/search.rb +9 -5
  77. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  78. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  79. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  80. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  81. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  82. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  83. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  84. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  85. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  86. data/lib/rubyn_code/teams/manager.rb +83 -5
  87. data/lib/rubyn_code/teams/teammate.rb +5 -1
  88. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  89. data/lib/rubyn_code/tools/executor.rb +5 -3
  90. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  91. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  92. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  93. data/lib/rubyn_code/tools/web_search.rb +4 -1
  94. data/lib/rubyn_code/version.rb +1 -1
  95. data/lib/rubyn_code.rb +45 -2
  96. data/skills/rubyn_self_test.md +322 -14
  97. data/skills/self_test/chisel_smoke.rb +84 -0
  98. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  99. metadata +37 -1
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubynCode
6
+ module Hooks
7
+ # Loads external hook commands from a Claude Code-compatible settings.json.
8
+ #
9
+ # Schema (matches Claude Code's hook config):
10
+ #
11
+ # {
12
+ # "hooks": {
13
+ # "PreToolUse": [
14
+ # {
15
+ # "matcher": "bash|write_file", // regex or "*"
16
+ # "hooks": [
17
+ # { "type": "command",
18
+ # "command": "/usr/local/bin/policy-check",
19
+ # "timeout": 60,
20
+ # "env": { "FOO": "bar" } // optional
21
+ # }
22
+ # ]
23
+ # }
24
+ # ],
25
+ # "PostToolUse": [ ... ],
26
+ # "UserPromptSubmit": [ ... ],
27
+ # "SessionStart": [ ... ],
28
+ # "SessionEnd": [ ... ],
29
+ # "Stop": [ ... ],
30
+ # "SubagentStop": [ ... ],
31
+ # "PreCompact": [ ... ],
32
+ # "Notification": [ ... ]
33
+ # }
34
+ # }
35
+ #
36
+ # "matcher" may be:
37
+ # - a regex string matched against the tool name (PreToolUse/PostToolUse)
38
+ # or the session id (other events accept any value);
39
+ # - "*" to match everything;
40
+ # - omitted/null to match everything.
41
+ #
42
+ # The loader does not validate that commands exist on disk — that's the
43
+ # Executor's job (it will fail at fire time with a clear error).
44
+ class SettingsJsonLoader
45
+ class LoadError < RubynCode::Error
46
+ end
47
+
48
+ # @return [Array<String>] paths the loader will try, in order
49
+ attr_reader :search_paths
50
+
51
+ # @param project_root [String] used to locate .rubyn-code/settings.json
52
+ # @param home_dir [String, nil] override for ~/.rubyn-code; defaults to Defaults::HOME_DIR
53
+ def initialize(project_root:, home_dir: nil)
54
+ @home_dir = home_dir || Config::Defaults::HOME_DIR
55
+ @project_root = project_root
56
+ @search_paths = [
57
+ File.join(@project_root, '.rubyn-code', 'settings.json'),
58
+ File.join(@home_dir, 'settings.json')
59
+ ].freeze
60
+ end
61
+
62
+ # Loads and merges settings.json from project + global.
63
+ #
64
+ # Project wins for the same matcher/event combination: if both files
65
+ # define a hook for the same event, the project's hook runs first and
66
+ # the global hook runs after, mirroring Claude Code's behaviour.
67
+ #
68
+ # @return [Hash<String, Array<Hash>>] { event_name => [matcher_group, ...] }
69
+ # where each matcher_group is { "matcher" => String, "hooks" => [command_hash, ...] }
70
+ def load
71
+ merged = {}
72
+ @search_paths.each do |path|
73
+ next unless File.exist?(path)
74
+
75
+ data = parse_file(path)
76
+ merge_into!(merged, data['hooks'] || {})
77
+ end
78
+ merged
79
+ end
80
+
81
+ private
82
+
83
+ def parse_file(path)
84
+ JSON.parse(File.read(path))
85
+ rescue JSON::ParserError => e
86
+ raise LoadError, "Failed to parse hook settings at #{path}: #{e.message}"
87
+ end
88
+
89
+ def merge_into!(merged, hooks_section)
90
+ hooks_section.each do |event_name, matcher_groups|
91
+ next unless matcher_groups.is_a?(Array)
92
+
93
+ merged[event_name] ||= []
94
+ matcher_groups.each do |group|
95
+ next unless group.is_a?(Hash)
96
+
97
+ commands = Array(group['hooks']).select { |h| h.is_a?(Hash) && h['type'] == 'command' }
98
+ next if commands.empty?
99
+
100
+ merged[event_name] << {
101
+ 'matcher' => group['matcher'] || '*',
102
+ 'hooks' => commands
103
+ }
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'timeout'
6
+
7
+ module RubynCode
8
+ module Hooks
9
+ # Spawns external hook commands and exchanges JSON with them.
10
+ #
11
+ # Protocol (matches Claude Code):
12
+ # 1. Spawn the command with { env, chdir: project_root }.
13
+ # 2. Write one JSON line to stdin:
14
+ # { "hookEventName": "PreToolUse",
15
+ # "sessionId": "...",
16
+ # "toolName": "bash", // when applicable
17
+ # "toolInput": { ... }, // when applicable
18
+ # "prompt": "user text..." } // when applicable
19
+ # 3. Close stdin.
20
+ # 4. Read stdout until EOF or timeout. Parse as JSON.
21
+ # - One JSON object spanning the whole output, OR
22
+ # - Newline-delimited JSON (first parseable line wins).
23
+ # 5. Stderr is captured and logged but not parsed.
24
+ #
25
+ # The executor is stateless — each call spawns a fresh process. This is
26
+ # intentional: hooks must not keep state between invocations, and process
27
+ # startup cost (~30ms on macOS) is negligible compared to typical tool
28
+ # execution time.
29
+ class SubprocessExecutor
30
+ DEFAULT_TIMEOUT = 60 # seconds
31
+
32
+ class ExecutionError < RubynCode::Error
33
+ end
34
+
35
+ class TimeoutError < ExecutionError
36
+ end
37
+
38
+ # @param project_root [String] working directory for spawned processes
39
+ # @param default_timeout [Integer] fallback timeout when a hook entry
40
+ # does not specify its own
41
+ def initialize(project_root:, default_timeout: DEFAULT_TIMEOUT)
42
+ @project_root = project_root
43
+ @default_timeout = default_timeout
44
+ end
45
+
46
+ # Runs a single hook command with the given event payload.
47
+ #
48
+ # @param command [String] executable path
49
+ # @param args [Array<String>] arguments (rarely used; settings.json
50
+ # typically embeds everything in the command string)
51
+ # @param env [Hash<String, String>] additional environment variables
52
+ # @param payload [Hash] the JSON payload (must include :hookEventName)
53
+ # @param timeout [Integer, nil] per-call timeout override
54
+ # @return [Hash] the parsed JSON response from stdout (empty hash if no output)
55
+ # @raise [ExecutionError] on spawn failure
56
+ # @raise [TimeoutError] if the command does not finish in time
57
+ def run(command:, payload:, args: [], env: {}, timeout: nil)
58
+ timeout ||= @default_timeout
59
+ env = default_env.merge(env)
60
+
61
+ stdout, _stderr, = invoke(command, args, env, payload, timeout)
62
+ parse_response(stdout)
63
+ rescue Timeout::Error => e
64
+ raise TimeoutError, "Hook command '#{command}' timed out after #{timeout}s: #{e.message}"
65
+ end
66
+
67
+ private
68
+
69
+ def default_env
70
+ # Open3 replaces the entire env when env: is given, so inherit the
71
+ # parent's environment first and add our hook-specific markers.
72
+ ENV.to_h.merge(
73
+ 'RUBYN_HOOK_EVENT' => '1',
74
+ 'CLAUDE_PROJECT_DIR' => @project_root
75
+ )
76
+ end
77
+
78
+ def invoke(command, args, env, payload, timeout)
79
+ Timeout.timeout(timeout) do
80
+ Open3.capture3(env, command, *args, chdir: @project_root, stdin_data: JSON.generate(payload))
81
+ end
82
+ rescue Errno::ENOENT => e
83
+ raise ExecutionError, "Hook command not found: #{command} (#{e.message})"
84
+ rescue SystemCallError => e
85
+ raise ExecutionError, "Failed to spawn hook command '#{command}': #{e.message}"
86
+ end
87
+
88
+ def parse_response(stdout)
89
+ return {} if stdout.nil? || stdout.strip.empty?
90
+
91
+ # Try whole-output JSON first (Claude Code's preferred shape).
92
+ begin
93
+ parsed = JSON.parse(stdout)
94
+ return parsed.is_a?(Hash) ? parsed : { 'output' => parsed }
95
+ rescue JSON::ParserError
96
+ # Fall through to line-delimited scanning.
97
+ end
98
+
99
+ stdout.each_line do |line|
100
+ stripped = line.strip
101
+ next if stripped.empty?
102
+
103
+ begin
104
+ parsed = JSON.parse(stripped)
105
+ return parsed.is_a?(Hash) ? parsed : { 'output' => parsed }
106
+ rescue JSON::ParserError
107
+ next
108
+ end
109
+ end
110
+
111
+ # Non-JSON output: treat as raw output for debugging hooks.
112
+ { 'output' => stdout }
113
+ end
114
+ end
115
+ end
116
+ end
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  module RubynCode
4
4
  module IDE
@@ -22,7 +22,7 @@ module RubynCode
22
22
  session = @server.lookup_interview_session(session_id)
23
23
  unless session
24
24
  raise Protocol::JsonRpcError.new(SESSION_NOT_FOUND_CODE,
25
- "Unknown interview session: #{session_id}")
25
+ "Unknown interview session: #{session_id}")
26
26
  end
27
27
 
28
28
  outcome = session.answer(question_id, answer)
@@ -34,9 +34,9 @@ module RubynCode
34
34
  Megaplan::PlanProposer::InvalidProposalError => e
35
35
  warn "[PlanInterviewAnswerHandler] interview failed: #{e.message}"
36
36
  @server.notify('plan/interview/error', {
37
- 'sessionId' => params['sessionId'],
38
- 'message' => e.message
39
- })
37
+ 'sessionId' => params['sessionId'],
38
+ 'message' => e.message
39
+ })
40
40
  @server.drop_interview_session(params['sessionId'].to_s)
41
41
  raise Protocol::JsonRpcError.new(INVALID_INTERVIEW_CODE, e.message)
42
42
  end
@@ -46,16 +46,16 @@ module RubynCode
46
46
  def emit_outcome(session, outcome)
47
47
  if outcome.is_a?(Megaplan::InterviewSession::Question)
48
48
  @server.notify('plan/interview/question', {
49
- 'sessionId' => session.session_id,
50
- 'questionId' => outcome.id,
51
- 'text' => outcome.text,
52
- 'options' => outcome.options
53
- })
49
+ 'sessionId' => session.session_id,
50
+ 'questionId' => outcome.id,
51
+ 'text' => outcome.text,
52
+ 'options' => outcome.options
53
+ })
54
54
  else
55
55
  @server.notify('plan/interview/done', {
56
- 'sessionId' => session.session_id,
57
- 'plan' => outcome
58
- })
56
+ 'sessionId' => session.session_id,
57
+ 'plan' => outcome
58
+ })
59
59
  @server.drop_interview_session(session.session_id)
60
60
  end
61
61
  end
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  module RubynCode
4
4
  module IDE
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  module RubynCode
4
4
  module IDE
@@ -12,7 +12,7 @@ module RubynCode
12
12
 
13
13
  def initialize(server, factory: nil)
14
14
  @server = server
15
- @factory = factory || ->(workspace_path:) {
15
+ @factory = factory || lambda { |workspace_path:|
16
16
  Megaplan::InterviewSession.new(workspace_path: workspace_path)
17
17
  }
18
18
  end
@@ -34,16 +34,16 @@ module RubynCode
34
34
  def emit_outcome(session, outcome)
35
35
  if outcome.is_a?(Megaplan::InterviewSession::Question)
36
36
  @server.notify('plan/interview/question', {
37
- 'sessionId' => session.session_id,
38
- 'questionId' => outcome.id,
39
- 'text' => outcome.text,
40
- 'options' => outcome.options
41
- })
37
+ 'sessionId' => session.session_id,
38
+ 'questionId' => outcome.id,
39
+ 'text' => outcome.text,
40
+ 'options' => outcome.options
41
+ })
42
42
  else
43
43
  @server.notify('plan/interview/done', {
44
- 'sessionId' => session.session_id,
45
- 'plan' => outcome
46
- })
44
+ 'sessionId' => session.session_id,
45
+ 'plan' => outcome
46
+ })
47
47
  @server.drop_interview_session(session.session_id)
48
48
  end
49
49
  end
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
4
 
@@ -57,7 +57,15 @@ module RubynCode
57
57
  return unless thread&.alive?
58
58
 
59
59
  thread.raise(Interrupt)
60
- thread.join(2) # give it a moment to clean up
60
+ begin
61
+ thread.join(2) # give it a moment to clean up
62
+ rescue Interrupt
63
+ # We asked the thread to stop, so its Interrupt is expected. If it
64
+ # lands before the agent installs its own rescue, Thread#join would
65
+ # otherwise re-raise it into the caller (the cancel path) and, in
66
+ # tests, escape the example and abort the whole run.
67
+ nil
68
+ end
61
69
  end
62
70
 
63
71
  private
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
4
 
@@ -36,18 +36,34 @@ module RubynCode
36
36
  private
37
37
 
38
38
  def run_recovery(session_id, context)
39
+ notify_recovering(session_id, context)
40
+
41
+ recovery = @recovery || Megaplan::CiRecovery.new(
42
+ agent_invoker: build_invoker(session_id)
43
+ )
44
+ outcome = recovery.recover(context)
45
+
46
+ notify_outcome(session_id, context, outcome)
47
+ @server.notify('agent/status', {
48
+ 'sessionId' => session_id,
49
+ 'status' => 'done',
50
+ 'summary' => outcome['summary']
51
+ })
52
+ rescue StandardError => e
53
+ warn "[RecoverCiHandler] error: #{e.message}"
54
+ notify_error(session_id, context, e)
55
+ end
56
+
57
+ def notify_recovering(session_id, context)
39
58
  @server.notify('agent/status', {
40
59
  'sessionId' => session_id,
41
60
  'status' => 'recovering',
42
61
  'phaseNumber' => context['phase_number'],
43
62
  'attemptNumber' => context['attempt_number']
44
63
  })
64
+ end
45
65
 
46
- recovery = @recovery || Megaplan::CiRecovery.new(
47
- agent_invoker: build_invoker(session_id)
48
- )
49
- outcome = recovery.recover(context)
50
-
66
+ def notify_outcome(session_id, context, outcome)
51
67
  @server.notify('recovery/outcome', {
52
68
  'sessionId' => session_id,
53
69
  'planId' => context['plan_id'],
@@ -56,25 +72,20 @@ module RubynCode
56
72
  'commitSha' => outcome['commit_sha'],
57
73
  'summary' => outcome['summary']
58
74
  })
75
+ end
59
76
 
60
- @server.notify('agent/status', {
61
- 'sessionId' => session_id,
62
- 'status' => 'done',
63
- 'summary' => outcome['summary']
64
- })
65
- rescue StandardError => e
66
- warn "[RecoverCiHandler] error: #{e.message}"
77
+ def notify_error(session_id, context, error)
67
78
  @server.notify('recovery/outcome', {
68
79
  'sessionId' => session_id,
69
80
  'planId' => context['plan_id'],
70
81
  'phaseNumber' => context['phase_number'],
71
82
  'kind' => 'errored',
72
- 'summary' => e.message
83
+ 'summary' => error.message
73
84
  })
74
85
  @server.notify('agent/status', {
75
86
  'sessionId' => session_id,
76
87
  'status' => 'error',
77
- 'error' => e.message
88
+ 'error' => error.message
78
89
  })
79
90
  end
80
91
 
@@ -87,7 +98,7 @@ module RubynCode
87
98
  llm_client = LLM::Client.new
88
99
  response = llm_client.chat(
89
100
  messages: [{ role: 'user', content: prompt }],
90
- on_text: ->(text) {
101
+ on_text: lambda { |text|
91
102
  @server.notify('stream/text', {
92
103
  'sessionId' => session_id,
93
104
  'text' => text,
@@ -30,7 +30,7 @@ module RubynCode
30
30
  prompt = @server.handler_instance(:prompt)
31
31
  if prompt
32
32
  conversation = Agent::Conversation.new
33
- messages.each { |msg| conversation.messages << msg }
33
+ conversation.replace!(messages.dup)
34
34
  prompt.inject_conversation(session_id, conversation)
35
35
  end
36
36
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'fileutils'
5
+ require 'open3'
5
6
 
6
7
  module RubynCode
7
8
  module Index
@@ -42,7 +43,9 @@ module RubynCode
42
43
 
43
44
  data = JSON.parse(File.read(@index_path))
44
45
  @nodes = data['nodes'] || []
45
- @edges = data['edges'] || []
46
+ # uniq drops duplicate edges accumulated by older versions, which
47
+ # appended tests edges on every update! without dedup.
48
+ @edges = (data['edges'] || []).uniq
46
49
  @file_mtimes = data['file_mtimes'] || {}
47
50
  self
48
51
  rescue StandardError
@@ -69,6 +72,19 @@ module RubynCode
69
72
  self
70
73
  end
71
74
 
75
+ # Incremental update for a single known-changed file (e.g. after a
76
+ # write_file/edit_file tool call). Avoids the full-tree scan in update!.
77
+ def update_file!(path)
78
+ absolute = File.expand_path(path, @project_root)
79
+ return self unless absolute.start_with?("#{@project_root}/")
80
+
81
+ remove_nodes_for(absolute)
82
+ index_file(absolute) if File.exist?(absolute)
83
+ extract_rails_edges
84
+ save!
85
+ self
86
+ end
87
+
72
88
  # Query the index for symbols matching a search term.
73
89
  def query(term)
74
90
  pattern = term.to_s.downcase
@@ -192,6 +208,26 @@ module RubynCode
192
208
  end
193
209
 
194
210
  def ruby_files
211
+ git_ruby_files || glob_ruby_files
212
+ end
213
+
214
+ # Prefer git's file list when available: it skips ignored dirs
215
+ # (tmp/, log/, coverage/, db/) the glob would index. --others picks up
216
+ # untracked-but-not-ignored files so freshly created ones still appear.
217
+ def git_ruby_files
218
+ return nil unless File.exist?(File.join(@project_root, '.git'))
219
+
220
+ stdout, status = Open3.capture2(
221
+ 'git', '-C', @project_root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.rb'
222
+ )
223
+ return nil unless status.success?
224
+
225
+ stdout.split("\0").map { |f| File.join(@project_root, f) }.select { |f| File.file?(f) }
226
+ rescue StandardError
227
+ nil
228
+ end
229
+
230
+ def glob_ruby_files
195
231
  Dir.glob(File.join(@project_root, '**', '*.rb'))
196
232
  .reject { |f| f.include?('/vendor/') || f.include?('/node_modules/') }
197
233
  end
@@ -253,6 +289,8 @@ module RubynCode
253
289
  end
254
290
 
255
291
  def extract_rails_edges
292
+ # Rebuild tests edges from scratch so repeated updates stay idempotent.
293
+ @edges.reject! { |e| e['relationship'] == 'tests' }
256
294
  spec_files = @file_mtimes.keys.select { |f| f.include?('spec/') || f.include?('test/') }
257
295
  spec_files.each do |spec_file|
258
296
  source = spec_file.sub(%r{spec/}, 'app/').sub(/_spec\.rb$/, '.rb')
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+
6
+ module RubynCode
7
+ module Learning
8
+ # Exports and imports learned instincts so a user can carry their
9
+ # accumulated learnings to another machine. Instincts live in SQLite under
10
+ # ~/.rubyn-code; this serializes them to a portable JSON file and loads
11
+ # them back, regenerating ids and de-duplicating by (project_path, pattern).
12
+ module Porter
13
+ FORMAT_VERSION = 1
14
+ # Columns carried across machines (id is regenerated on import).
15
+ COLUMNS = %w[
16
+ project_path pattern context_tags confidence decay_rate
17
+ times_applied times_helpful created_at updated_at
18
+ ].freeze
19
+
20
+ class Error < RubynCode::Error; end
21
+
22
+ class << self
23
+ # Export instincts to a JSON file.
24
+ #
25
+ # @param db [DB::Connection]
26
+ # @param path [String] destination file
27
+ # @param project_path [String, nil] limit to one project, or nil for all
28
+ # @return [Integer] number of instincts exported
29
+ def export(db:, path:, project_path: nil)
30
+ rows = fetch(db, project_path)
31
+ payload = { 'version' => FORMAT_VERSION, 'instincts' => rows }
32
+ File.write(path, "#{JSON.pretty_generate(payload)}\n")
33
+ rows.size
34
+ end
35
+
36
+ # Import instincts from a JSON file.
37
+ #
38
+ # @param db [DB::Connection]
39
+ # @param path [String] source file
40
+ # @param remap_project [String, nil] override every row's project_path
41
+ # (use the current project so imported learnings apply here)
42
+ # @return [Hash] { imported:, skipped:, total: }
43
+ def import(db:, path:, remap_project: nil)
44
+ raise Error, "File not found: #{path}" unless File.file?(path)
45
+
46
+ payload = parse(path)
47
+ instincts = Array(payload['instincts'])
48
+ imported = instincts.count { |row| import_row(db, row, remap_project) }
49
+
50
+ { imported: imported, skipped: instincts.size - imported, total: instincts.size }
51
+ end
52
+
53
+ # @return [Hash] { count:, projects: } summary for display
54
+ def stats(db, project_path: nil)
55
+ rows = fetch(db, project_path)
56
+ { count: rows.size, projects: rows.map { |r| r['project_path'] }.uniq.size }
57
+ end
58
+
59
+ private
60
+
61
+ def fetch(db, project_path)
62
+ select = "SELECT #{COLUMNS.join(', ')} FROM instincts"
63
+ if project_path
64
+ db.query("#{select} WHERE project_path = ?", [project_path]).to_a
65
+ else
66
+ db.query(select).to_a
67
+ end
68
+ end
69
+
70
+ def parse(path)
71
+ payload = JSON.parse(File.read(path))
72
+ raise Error, 'Not a Rubyn learnings file' unless payload.is_a?(Hash) && payload.key?('instincts')
73
+
74
+ version = payload['version'].to_i
75
+ raise Error, "Unsupported export version: #{version}" if version > FORMAT_VERSION
76
+
77
+ payload
78
+ rescue JSON::ParserError => e
79
+ raise Error, "Invalid JSON: #{e.message}"
80
+ end
81
+
82
+ # @return [Boolean] true if inserted, false if skipped (duplicate)
83
+ def import_row(db, row, remap_project)
84
+ project = remap_project || row['project_path']
85
+ return false if project.to_s.empty? || row['pattern'].to_s.empty?
86
+ return false if exists?(db, project, row['pattern'])
87
+
88
+ insert(db, row, project)
89
+ true
90
+ rescue StandardError => e
91
+ RubynCode::Debug.warn("Skipping instinct import: #{e.message}")
92
+ false
93
+ end
94
+
95
+ def exists?(db, project, pattern)
96
+ db.query(
97
+ 'SELECT 1 FROM instincts WHERE project_path = ? AND pattern = ? LIMIT 1',
98
+ [project, pattern]
99
+ ).to_a.any?
100
+ end
101
+
102
+ def insert(db, row, project)
103
+ now = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
104
+ db.execute(
105
+ <<~SQL.tr("\n", ' ').strip,
106
+ INSERT INTO instincts (id, project_path, pattern, context_tags,
107
+ confidence, decay_rate, times_applied, times_helpful, created_at, updated_at)
108
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
+ SQL
110
+ [
111
+ SecureRandom.uuid, project, row['pattern'], normalize_tags(row['context_tags']),
112
+ (row['confidence'] || 0.5).to_f, (row['decay_rate'] || 0.05).to_f,
113
+ (row['times_applied'] || 0).to_i, (row['times_helpful'] || 0).to_i,
114
+ row['created_at'] || now, row['updated_at'] || now
115
+ ]
116
+ )
117
+ end
118
+
119
+ # context_tags is stored as a JSON string column; accept either a JSON
120
+ # string or an array from the export file.
121
+ def normalize_tags(tags)
122
+ return tags if tags.is_a?(String)
123
+
124
+ JSON.generate(Array(tags))
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end