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.
- checksums.yaml +4 -4
- data/README.md +120 -3
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +89 -3
- data/lib/rubyn_code/agent/llm_caller.rb +2 -2
- data/lib/rubyn_code/agent/loop.rb +49 -9
- data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
- data/lib/rubyn_code/agent/tool_processor.rb +3 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/checkpoint/hook.rb +26 -0
- data/lib/rubyn_code/checkpoint/manager.rb +109 -0
- data/lib/rubyn_code/chisel/debt.rb +65 -0
- data/lib/rubyn_code/chisel/inspection.rb +93 -0
- data/lib/rubyn_code/chisel.rb +127 -0
- data/lib/rubyn_code/cli/commands/agents.rb +31 -0
- data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
- data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
- data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
- data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
- data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
- data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
- data/lib/rubyn_code/cli/commands/context.rb +3 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
- data/lib/rubyn_code/cli/commands/goal.rb +87 -0
- data/lib/rubyn_code/cli/commands/learning.rb +62 -0
- data/lib/rubyn_code/cli/commands/loop.rb +58 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
- data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/loop_runner.rb +98 -0
- data/lib/rubyn_code/cli/mention_expander.rb +92 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +37 -14
- data/lib/rubyn_code/cli/repl_commands.rb +76 -2
- data/lib/rubyn_code/cli/repl_setup.rb +9 -1
- data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
- data/lib/rubyn_code/cli/version_check.rb +10 -3
- data/lib/rubyn_code/config/defaults.rb +13 -1
- data/lib/rubyn_code/config/schema.json +4 -0
- data/lib/rubyn_code/config/settings.rb +17 -2
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/debug.rb +11 -5
- data/lib/rubyn_code/goal/evaluator.rb +95 -0
- data/lib/rubyn_code/hooks/event_map.rb +56 -0
- data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
- data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
- data/lib/rubyn_code/hooks/response.rb +83 -0
- data/lib/rubyn_code/hooks/runner.rb +61 -3
- data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
- data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +39 -1
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
- data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
- data/lib/rubyn_code/llm/model_router.rb +2 -2
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
- data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
- data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
- data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +9 -5
- data/lib/rubyn_code/memory/session_persistence.rb +159 -21
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
- data/lib/rubyn_code/skills/registry_client.rb +4 -3
- data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
- data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
- data/lib/rubyn_code/teams/agent_registry.rb +120 -0
- data/lib/rubyn_code/teams/mailbox.rb +99 -10
- data/lib/rubyn_code/teams/manager.rb +83 -5
- data/lib/rubyn_code/teams/teammate.rb +5 -1
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/executor.rb +5 -3
- data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
- data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
- data/lib/rubyn_code/tools/web_fetch.rb +1 -1
- data/lib/rubyn_code/tools/web_search.rb +4 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +45 -2
- data/skills/rubyn_self_test.md +322 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
@@ -57,7 +57,15 @@ module RubynCode
|
|
|
57
57
|
return unless thread&.alive?
|
|
58
58
|
|
|
59
59
|
thread.raise(Interrupt)
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' =>
|
|
83
|
+
'summary' => error.message
|
|
73
84
|
})
|
|
74
85
|
@server.notify('agent/status', {
|
|
75
86
|
'sessionId' => session_id,
|
|
76
87
|
'status' => 'error',
|
|
77
|
-
'error' =>
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|