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,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# `/chisel-review` — audit the current branch's changes for
|
|
7
|
+
# over-engineering and report a ranked deletion list. Read-only.
|
|
8
|
+
class ChiselReview < Base
|
|
9
|
+
def self.command_name = '/chisel-review'
|
|
10
|
+
def self.description = 'Find over-engineering in the current diff (/chisel-review [base])'
|
|
11
|
+
|
|
12
|
+
def execute(args, ctx)
|
|
13
|
+
# Inspection owns the 'main' default so it lives in exactly one place.
|
|
14
|
+
ctx.send_message(RubynCode::Chisel::Inspection.prompt(scope: :diff, target: args.first))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module CLI
|
|
7
|
+
module Commands
|
|
8
|
+
# Renders a user-defined slash-command body into a prompt, mirroring
|
|
9
|
+
# Claude Code's command templating:
|
|
10
|
+
#
|
|
11
|
+
# $ARGUMENTS → all args, space-joined
|
|
12
|
+
# $1 .. $9 → positional args
|
|
13
|
+
# !`shell cmd` → replaced with the command's (combined) output
|
|
14
|
+
#
|
|
15
|
+
# Bash substitution runs the user's own command file, so it carries the
|
|
16
|
+
# same trust as user hooks. Output is captured defensively and capped.
|
|
17
|
+
class CommandTemplate
|
|
18
|
+
BANG = /!`([^`]+)`/
|
|
19
|
+
POSITIONAL = /\$([1-9])/
|
|
20
|
+
MAX_BASH_OUTPUT = 16 * 1024
|
|
21
|
+
|
|
22
|
+
def initialize(body)
|
|
23
|
+
@body = body.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param args [Array<String>] arguments passed after the command name
|
|
27
|
+
# @return [String] the rendered prompt
|
|
28
|
+
def render(args = [])
|
|
29
|
+
text = substitute_bash(@body)
|
|
30
|
+
text = text.gsub('$ARGUMENTS', args.join(' '))
|
|
31
|
+
text.gsub(POSITIONAL) { args[Regexp.last_match(1).to_i - 1].to_s }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def substitute_bash(text)
|
|
37
|
+
text.gsub(BANG) { run(Regexp.last_match(1)) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run(cmd)
|
|
41
|
+
out, = Open3.capture2e(cmd)
|
|
42
|
+
out = "#{out.byteslice(0, MAX_BASH_OUTPUT)}\n… [truncated]" if out.bytesize > MAX_BASH_OUTPUT
|
|
43
|
+
out.strip
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
"[command failed: #{e.message}]"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -21,7 +21,9 @@ module RubynCode
|
|
|
21
21
|
:background_worker,
|
|
22
22
|
:permission_tier,
|
|
23
23
|
:plan_mode,
|
|
24
|
-
:message_handler
|
|
24
|
+
:message_handler,
|
|
25
|
+
:hook_registry,
|
|
26
|
+
:checkpoint_manager
|
|
25
27
|
) do
|
|
26
28
|
# Convenience: return a new Context with a message handler attached.
|
|
27
29
|
# Used by commands like /review that delegate to the LLM.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# A slash command defined by a user markdown file (loaded by
|
|
7
|
+
# CustomLoader). Unlike the built-in commands — which are registered as
|
|
8
|
+
# classes — this is a ready instance: the Registry dispatches it directly.
|
|
9
|
+
#
|
|
10
|
+
# Executing it renders the template (argument / bash substitution) and
|
|
11
|
+
# sends the result to the agent as a normal prompt.
|
|
12
|
+
class CustomCommand
|
|
13
|
+
# @return [String] command name without the leading slash
|
|
14
|
+
attr_reader :name
|
|
15
|
+
# @return [String] one-line description for /help
|
|
16
|
+
attr_reader :description
|
|
17
|
+
# @return [String, nil] originating file path
|
|
18
|
+
attr_reader :source
|
|
19
|
+
|
|
20
|
+
def initialize(name:, description:, body:, source: nil)
|
|
21
|
+
@name = name
|
|
22
|
+
@description = description
|
|
23
|
+
@template = CommandTemplate.new(body)
|
|
24
|
+
@source = source
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def command_name = "/#{@name}"
|
|
28
|
+
def aliases = [].freeze
|
|
29
|
+
def hidden? = false
|
|
30
|
+
def all_names = [command_name].freeze
|
|
31
|
+
|
|
32
|
+
# @param args [Array<String>]
|
|
33
|
+
# @param ctx [Commands::Context]
|
|
34
|
+
# @return [nil]
|
|
35
|
+
def execute(args, ctx)
|
|
36
|
+
ctx.send_message(@template.render(args))
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module CLI
|
|
7
|
+
module Commands
|
|
8
|
+
# Discovers user-defined slash commands from markdown files, mirroring
|
|
9
|
+
# Claude Code's `.claude/commands/*.md`:
|
|
10
|
+
#
|
|
11
|
+
# <project>/.rubyn-code/commands/*.md (project-local, takes priority)
|
|
12
|
+
# ~/.rubyn-code/commands/*.md (user-global)
|
|
13
|
+
#
|
|
14
|
+
# Each `deploy.md` becomes `/deploy`. Optional YAML frontmatter supplies
|
|
15
|
+
# the `description`; otherwise the first non-empty line is used. The body
|
|
16
|
+
# is the prompt template (see CommandTemplate for substitutions).
|
|
17
|
+
module CustomLoader
|
|
18
|
+
FRONTMATTER = /\A---\s*\n(.+?\n)---\s*\n(.*)\z/m
|
|
19
|
+
NAME = /\A[a-z0-9][a-z0-9_-]*\z/i
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# @return [Array<CustomCommand>] unique commands (project overrides user)
|
|
23
|
+
def load_all(project_root:, home_dir: Config::Defaults::HOME_DIR)
|
|
24
|
+
dirs = [
|
|
25
|
+
project_root && File.join(project_root, '.rubyn-code', 'commands'),
|
|
26
|
+
File.join(home_dir, 'commands')
|
|
27
|
+
].compact
|
|
28
|
+
dirs.flat_map { |dir| load_dir(dir) }.uniq(&:command_name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def load_dir(dir)
|
|
32
|
+
return [] unless Dir.exist?(dir)
|
|
33
|
+
|
|
34
|
+
Dir.glob(File.join(dir, '*.md')).filter_map { |path| build(path) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def build(path)
|
|
40
|
+
name = File.basename(path, '.md').strip
|
|
41
|
+
return nil unless name.match?(NAME)
|
|
42
|
+
|
|
43
|
+
description, body = parse(File.read(path))
|
|
44
|
+
description = "Custom command: /#{name}" if description.to_s.strip.empty?
|
|
45
|
+
CustomCommand.new(name: name, description: description, body: body, source: path)
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
RubynCode::Debug.warn("Failed to load custom command #{path}: #{e.message}")
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse(content)
|
|
52
|
+
if (match = FRONTMATTER.match(content))
|
|
53
|
+
frontmatter = YAML.safe_load(match[1]) || {}
|
|
54
|
+
[frontmatter['description'].to_s, match[2].to_s.strip]
|
|
55
|
+
else
|
|
56
|
+
body = content.to_s.strip
|
|
57
|
+
[first_line(body), body]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def first_line(body)
|
|
62
|
+
line = body.lines.first.to_s.strip
|
|
63
|
+
line.sub(/\A#+\s*/, '')[0, 80]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# `/goal` — set a session goal that Rubyn keeps working toward until it
|
|
7
|
+
# is met. Installs a Stop hook (Hooks::GoalHook) that blocks the agent
|
|
8
|
+
# from finishing while the goal is unmet; the goal auto-clears once an
|
|
9
|
+
# evaluator judges it satisfied.
|
|
10
|
+
#
|
|
11
|
+
# /goal <condition> set a goal and start working toward it
|
|
12
|
+
# /goal show the current goal (if any)
|
|
13
|
+
# /goal clear cancel the active goal early
|
|
14
|
+
class Goal < Base
|
|
15
|
+
def self.command_name = '/goal'
|
|
16
|
+
def self.description = 'Set a goal Rubyn works toward until met (/goal clear to cancel)'
|
|
17
|
+
|
|
18
|
+
CLEAR_WORDS = %w[clear cancel off stop].freeze
|
|
19
|
+
|
|
20
|
+
def execute(args, ctx)
|
|
21
|
+
first = args.first&.strip&.downcase
|
|
22
|
+
return clear_goal(ctx) if CLEAR_WORDS.include?(first)
|
|
23
|
+
|
|
24
|
+
condition = args.join(' ').strip
|
|
25
|
+
return show_status(ctx) if condition.empty?
|
|
26
|
+
|
|
27
|
+
set_goal(ctx, condition)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def set_goal(ctx, condition)
|
|
33
|
+
deactivate_existing(ctx)
|
|
34
|
+
evaluator = RubynCode::Goal::Evaluator.new(llm_client: ctx.llm_client)
|
|
35
|
+
ctx.hook_registry.on(:stop, Hooks::GoalHook.new(condition: condition, evaluator: evaluator), priority: 10)
|
|
36
|
+
|
|
37
|
+
ctx.renderer.info("🎯 Goal set: #{condition}")
|
|
38
|
+
ctx.renderer.info("Rubyn will keep working until it's met. /goal clear to cancel.")
|
|
39
|
+
ctx.send_message(kickoff_prompt(condition))
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clear_goal(ctx)
|
|
44
|
+
if deactivate_existing(ctx).positive?
|
|
45
|
+
ctx.renderer.info('Goal cleared. ✌️')
|
|
46
|
+
else
|
|
47
|
+
ctx.renderer.info('No active goal to clear.')
|
|
48
|
+
end
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def show_status(ctx)
|
|
53
|
+
active = active_goals(ctx)
|
|
54
|
+
if active.empty?
|
|
55
|
+
ctx.renderer.info('No active goal. Set one with: /goal <what you want done>')
|
|
56
|
+
else
|
|
57
|
+
ctx.renderer.info("🎯 Active goal: #{active.first.condition}")
|
|
58
|
+
end
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Deactivate any active GoalHook(s) on the registry.
|
|
63
|
+
# @return [Integer] number of goals deactivated
|
|
64
|
+
def deactivate_existing(ctx)
|
|
65
|
+
active_goals(ctx).each(&:clear!).size
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def active_goals(ctx)
|
|
69
|
+
return [] unless ctx.hook_registry
|
|
70
|
+
|
|
71
|
+
ctx.hook_registry.hooks_for(:stop).select do |hook|
|
|
72
|
+
hook.is_a?(Hooks::GoalHook) && hook.active?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def kickoff_prompt(condition)
|
|
77
|
+
<<~PROMPT.strip
|
|
78
|
+
New session goal: #{condition}
|
|
79
|
+
|
|
80
|
+
Start working toward this goal now. Keep going until it is genuinely
|
|
81
|
+
met — don't stop to ask what to do next.
|
|
82
|
+
PROMPT
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# `/learning` — inspect and move the instincts Rubyn learns across
|
|
7
|
+
# sessions. Continuous learning runs automatically at the end of each
|
|
8
|
+
# session; this command lets you carry those learnings to another machine.
|
|
9
|
+
#
|
|
10
|
+
# /learning show learning stats
|
|
11
|
+
# /learning export [path] export all instincts to a JSON file
|
|
12
|
+
# /learning import <path> [--here] import instincts (--here = remap to this project)
|
|
13
|
+
class Learning < Base
|
|
14
|
+
def self.command_name = '/learning'
|
|
15
|
+
def self.description = 'Show, export, or import learned instincts (/learning export|import)'
|
|
16
|
+
|
|
17
|
+
DEFAULT_FILE = 'rubyn-learnings.json'
|
|
18
|
+
|
|
19
|
+
def execute(args, ctx)
|
|
20
|
+
case args.first
|
|
21
|
+
when 'export' then export(ctx, args[1])
|
|
22
|
+
when 'import' then import(ctx, args[1..])
|
|
23
|
+
when nil then stats(ctx)
|
|
24
|
+
else
|
|
25
|
+
ctx.renderer.info('Usage: /learning [export [path] | import <path> [--here]]')
|
|
26
|
+
end
|
|
27
|
+
nil
|
|
28
|
+
rescue RubynCode::Learning::Porter::Error => e
|
|
29
|
+
ctx.renderer.error("Learning: #{e.message}")
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def stats(ctx)
|
|
36
|
+
summary = RubynCode::Learning::Porter.stats(ctx.db)
|
|
37
|
+
ctx.renderer.info("🧠 #{summary[:count]} instinct(s) learned across #{summary[:projects]} project(s).")
|
|
38
|
+
ctx.renderer.info('Move them with: /learning export → /learning import <file> --here')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def export(ctx, path)
|
|
42
|
+
dest = File.expand_path(path || DEFAULT_FILE, ctx.project_root)
|
|
43
|
+
count = RubynCode::Learning::Porter.export(db: ctx.db, path: dest)
|
|
44
|
+
ctx.renderer.info("📤 Exported #{count} instinct(s) to #{dest}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def import(ctx, rest)
|
|
48
|
+
args = Array(rest)
|
|
49
|
+
here = args.delete('--here')
|
|
50
|
+
path = args.first
|
|
51
|
+
return ctx.renderer.info('Usage: /learning import <path> [--here]') unless path
|
|
52
|
+
|
|
53
|
+
remap = here ? ctx.project_root : nil
|
|
54
|
+
result = RubynCode::Learning::Porter.import(
|
|
55
|
+
db: ctx.db, path: File.expand_path(path, ctx.project_root), remap_project: remap
|
|
56
|
+
)
|
|
57
|
+
ctx.renderer.info("📥 Imported #{result[:imported]} instinct(s) (#{result[:skipped]} skipped as duplicates).")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# `/loop` — run a prompt or slash command repeatedly, mirroring Claude
|
|
7
|
+
# Code's /loop. Either on a fixed interval, or self-paced (the agent
|
|
8
|
+
# decides when the recurring task is done).
|
|
9
|
+
#
|
|
10
|
+
# /loop 5m /review run /review every 5 minutes
|
|
11
|
+
# /loop 30s check the deploy send a prompt every 30 seconds
|
|
12
|
+
# /loop x10 5m /babysit-prs ...at most 10 times
|
|
13
|
+
# /loop keep triaging issues self-paced (until the agent emits LOOP_DONE)
|
|
14
|
+
#
|
|
15
|
+
# Returns a :run_loop action; the REPL owns the actual loop so Ctrl-C
|
|
16
|
+
# stops it cleanly and slash payloads can be re-dispatched.
|
|
17
|
+
class Loop < Base
|
|
18
|
+
def self.command_name = '/loop'
|
|
19
|
+
def self.description = 'Repeat a prompt or slash command on an interval (/loop 5m /review)'
|
|
20
|
+
|
|
21
|
+
MAX_TOKEN = /\Ax(\d+)\z/i
|
|
22
|
+
|
|
23
|
+
def execute(args, ctx)
|
|
24
|
+
return usage(ctx) if args.empty?
|
|
25
|
+
|
|
26
|
+
max, rest = extract_max(args)
|
|
27
|
+
interval = LoopRunner.parse_interval(rest.first)
|
|
28
|
+
payload = (interval ? rest[1..] : rest).join(' ').strip
|
|
29
|
+
return usage(ctx) if payload.empty?
|
|
30
|
+
|
|
31
|
+
{ action: :run_loop, interval: interval, max: max, payload: payload }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Pull an optional leading "xN" max-iterations token from anywhere in
|
|
37
|
+
# the first two positions (so both `/loop x5 5m ...` and `/loop 5m ...`
|
|
38
|
+
# read naturally).
|
|
39
|
+
def extract_max(args)
|
|
40
|
+
idx = args[0, 2].index { |a| a.match?(MAX_TOKEN) }
|
|
41
|
+
return [LoopRunner::DEFAULT_MAX_ITERATIONS, args] unless idx
|
|
42
|
+
|
|
43
|
+
max = args[idx][MAX_TOKEN, 1].to_i
|
|
44
|
+
[max.positive? ? max : LoopRunner::DEFAULT_MAX_ITERATIONS, args[0...idx] + args[(idx + 1)..]]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def usage(ctx)
|
|
48
|
+
ctx.renderer.info('Usage: /loop [xN] [interval] <prompt-or-/command>')
|
|
49
|
+
ctx.renderer.info(' /loop 5m /review — run /review every 5 minutes')
|
|
50
|
+
ctx.renderer.info(' /loop 30s check status — send a prompt every 30s')
|
|
51
|
+
ctx.renderer.info(' /loop x3 1m /babysit — at most 3 times')
|
|
52
|
+
ctx.renderer.info(' /loop keep triaging — self-paced (Ctrl-C to stop)')
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -30,24 +30,37 @@ module RubynCode
|
|
|
30
30
|
|
|
31
31
|
def render_server(cfg)
|
|
32
32
|
client = build_client(cfg)
|
|
33
|
-
status,
|
|
33
|
+
status, counts = probe_server(client)
|
|
34
34
|
icon = status_icon(status)
|
|
35
|
-
tools_label = tool_count ? " (#{tool_count} tools)" : ''
|
|
36
35
|
|
|
37
|
-
puts " #{icon} #{cfg[:name]} [#{status}]#{
|
|
36
|
+
puts " #{icon} #{cfg[:name]} [#{status}]#{capability_label(counts)}"
|
|
38
37
|
render_transport_info(cfg)
|
|
39
38
|
ensure
|
|
40
39
|
client&.disconnect! if client&.connected?
|
|
41
40
|
end
|
|
42
41
|
|
|
42
|
+
def capability_label(counts)
|
|
43
|
+
return '' unless counts
|
|
44
|
+
|
|
45
|
+
parts = []
|
|
46
|
+
parts << "#{counts[:tools]} tools"
|
|
47
|
+
parts << "#{counts[:resources]} resources" if counts[:resources].positive?
|
|
48
|
+
parts << "#{counts[:prompts]} prompts" if counts[:prompts].positive?
|
|
49
|
+
" (#{parts.join(', ')})"
|
|
50
|
+
end
|
|
51
|
+
|
|
43
52
|
def build_client(cfg)
|
|
44
53
|
MCP::Client.from_config(cfg)
|
|
45
54
|
end
|
|
46
55
|
|
|
47
56
|
def probe_server(client)
|
|
48
57
|
client.connect!
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
counts = {
|
|
59
|
+
tools: client.tools.size,
|
|
60
|
+
resources: client.resources.size,
|
|
61
|
+
prompts: client.prompts.size
|
|
62
|
+
}
|
|
63
|
+
[:connected, counts]
|
|
51
64
|
rescue StandardError
|
|
52
65
|
[:error, nil]
|
|
53
66
|
end
|
|
@@ -13,14 +13,16 @@ module RubynCode
|
|
|
13
13
|
@classes = []
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
# Register a command class
|
|
16
|
+
# Register a command. Accepts either a command class (built-ins, which
|
|
17
|
+
# are instantiated per dispatch) or a ready command instance
|
|
18
|
+
# (user-defined; see Commands::CustomCommand).
|
|
17
19
|
#
|
|
18
|
-
# @param
|
|
20
|
+
# @param command [Class<Commands::Base>, #execute]
|
|
19
21
|
# @return [void]
|
|
20
|
-
def register(
|
|
21
|
-
@classes <<
|
|
22
|
-
|
|
23
|
-
@commands[name] =
|
|
22
|
+
def register(command)
|
|
23
|
+
@classes << command
|
|
24
|
+
command.all_names.each do |name|
|
|
25
|
+
@commands[name] = command
|
|
24
26
|
end
|
|
25
27
|
end
|
|
26
28
|
|
|
@@ -31,10 +33,13 @@ module RubynCode
|
|
|
31
33
|
# @param ctx [Commands::Context] shared context
|
|
32
34
|
# @return [Symbol, nil] :quit if the command signals exit, nil otherwise
|
|
33
35
|
def dispatch(name, args, ctx)
|
|
34
|
-
|
|
35
|
-
return :unknown unless
|
|
36
|
+
command = @commands[name]
|
|
37
|
+
return :unknown unless command
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
# Built-ins are registered as classes (instantiate per call);
|
|
40
|
+
# user-defined commands are registered as ready instances.
|
|
41
|
+
instance = command.respond_to?(:new) ? command.new : command
|
|
42
|
+
instance.execute(args, ctx)
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
# All registered command names (for tab completion).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# `/rewind` — restore the session to an earlier checkpoint, mirroring
|
|
7
|
+
# Claude Code's /rewind. A checkpoint is taken at the start of each user
|
|
8
|
+
# turn, capturing the conversation and the original contents of any files
|
|
9
|
+
# changed that turn.
|
|
10
|
+
#
|
|
11
|
+
# /rewind list checkpoints
|
|
12
|
+
# /rewind <id> restore code + conversation to checkpoint <id>
|
|
13
|
+
# /rewind <id> code restore only the files
|
|
14
|
+
# /rewind <id> chat restore only the conversation
|
|
15
|
+
class Rewind < Base
|
|
16
|
+
def self.command_name = '/rewind'
|
|
17
|
+
def self.description = 'Rewind to an earlier checkpoint (/rewind to list)'
|
|
18
|
+
|
|
19
|
+
SCOPES = { 'code' => :code, 'chat' => :chat, 'both' => :both }.freeze
|
|
20
|
+
|
|
21
|
+
def execute(args, ctx)
|
|
22
|
+
manager = ctx.checkpoint_manager
|
|
23
|
+
return ctx.renderer.info('Checkpoints are not available in this session.') unless manager
|
|
24
|
+
|
|
25
|
+
id = args.first
|
|
26
|
+
return list(manager, ctx) if id.nil?
|
|
27
|
+
|
|
28
|
+
restore(manager, ctx, id, args[1])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def list(manager, ctx)
|
|
34
|
+
checkpoints = manager.list
|
|
35
|
+
return ctx.renderer.info('No checkpoints yet — they are created as you work.') if checkpoints.empty?
|
|
36
|
+
|
|
37
|
+
ctx.renderer.info('Checkpoints (newest last):')
|
|
38
|
+
checkpoints.each do |cp|
|
|
39
|
+
puts " #{cp[:id].to_s.rjust(3)} #{cp[:files]} file(s) — #{cp[:label]}"
|
|
40
|
+
end
|
|
41
|
+
puts
|
|
42
|
+
ctx.renderer.info('Restore with: /rewind <id> [code|chat]')
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def restore(manager, ctx, id_arg, scope_arg)
|
|
47
|
+
scope = SCOPES.fetch(scope_arg.to_s, :both)
|
|
48
|
+
result = manager.restore(id_arg.to_i, ctx.conversation, scope: scope)
|
|
49
|
+
return ctx.renderer.warning("No checkpoint ##{id_arg}.") unless result
|
|
50
|
+
|
|
51
|
+
ctx.renderer.info(restored_message(result, scope))
|
|
52
|
+
{ action: :rewound }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def restored_message(result, scope)
|
|
56
|
+
case scope
|
|
57
|
+
when :code then "⏪ Restored #{result[:files_restored]} file(s) to checkpoint ##{result[:id]}."
|
|
58
|
+
when :chat then "⏪ Restored conversation to checkpoint ##{result[:id]}."
|
|
59
|
+
else "⏪ Rewound to checkpoint ##{result[:id]} (#{result[:files_restored]} file(s) + conversation)."
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
# Drives a repeating execution of a payload (a prompt or slash command).
|
|
6
|
+
#
|
|
7
|
+
# Pure orchestration with injected dependencies so it can be unit-tested
|
|
8
|
+
# without a REPL: the caller supplies a `runner` callable that performs one
|
|
9
|
+
# iteration and a `sleeper` for the wait between iterations.
|
|
10
|
+
#
|
|
11
|
+
# Two modes:
|
|
12
|
+
# - interval mode (interval is a positive number of seconds): run, wait,
|
|
13
|
+
# run, ... until max_iterations or stopped.
|
|
14
|
+
# - self-paced mode (interval is nil): run back-to-back until the runner
|
|
15
|
+
# returns :stop, emits the DONE_SENTINEL, or max_iterations is hit.
|
|
16
|
+
class LoopRunner
|
|
17
|
+
DEFAULT_MAX_ITERATIONS = 50
|
|
18
|
+
DONE_SENTINEL = 'LOOP_DONE'
|
|
19
|
+
UNITS = { 's' => 1, 'm' => 60, 'h' => 3600 }.freeze
|
|
20
|
+
|
|
21
|
+
# Parse an interval token like "30s", "5m", "2h", or a bare "45"
|
|
22
|
+
# (seconds). Returns nil when the token is not a valid interval.
|
|
23
|
+
#
|
|
24
|
+
# @param token [String, nil]
|
|
25
|
+
# @return [Integer, nil] seconds, or nil
|
|
26
|
+
def self.parse_interval(token)
|
|
27
|
+
return nil if token.nil?
|
|
28
|
+
|
|
29
|
+
match = token.strip.match(/\A(\d+)\s*([smh]?)\z/i)
|
|
30
|
+
return nil unless match
|
|
31
|
+
|
|
32
|
+
amount = match[1].to_i
|
|
33
|
+
return nil if amount <= 0
|
|
34
|
+
|
|
35
|
+
amount * UNITS.fetch(match[2].downcase, 1)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param interval [Integer, nil] seconds between runs; nil => self-paced
|
|
39
|
+
# @param runner [#call] ->(iteration_index) => String|:stop
|
|
40
|
+
# @param max_iterations [Integer] hard cap on iterations
|
|
41
|
+
# @param sleeper [#call, nil] ->(seconds); defaults to Kernel#sleep
|
|
42
|
+
# @param on_iteration [#call, nil] ->(n, total) UI callback before each run
|
|
43
|
+
def initialize(runner:, interval: nil, max_iterations: DEFAULT_MAX_ITERATIONS,
|
|
44
|
+
sleeper: nil, on_iteration: nil)
|
|
45
|
+
@interval = interval
|
|
46
|
+
@runner = runner
|
|
47
|
+
@max_iterations = max_iterations
|
|
48
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
49
|
+
@on_iteration = on_iteration
|
|
50
|
+
@stopped = false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Request the loop to stop after the current iteration.
|
|
54
|
+
# @return [void]
|
|
55
|
+
def stop!
|
|
56
|
+
@stopped = true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def stopped? = @stopped
|
|
61
|
+
|
|
62
|
+
# Run the loop.
|
|
63
|
+
#
|
|
64
|
+
# @return [Integer] number of iterations completed
|
|
65
|
+
def run
|
|
66
|
+
completed = 0
|
|
67
|
+
@max_iterations.times do |index|
|
|
68
|
+
break if @stopped
|
|
69
|
+
|
|
70
|
+
@on_iteration&.call(index + 1, @max_iterations)
|
|
71
|
+
result = @runner.call(index)
|
|
72
|
+
completed += 1
|
|
73
|
+
|
|
74
|
+
break if @stopped || done?(result)
|
|
75
|
+
|
|
76
|
+
wait_before_next(index)
|
|
77
|
+
end
|
|
78
|
+
completed
|
|
79
|
+
rescue Interrupt
|
|
80
|
+
completed
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def wait_before_next(index)
|
|
86
|
+
return unless @interval && index < @max_iterations - 1
|
|
87
|
+
|
|
88
|
+
@sleeper.call(@interval)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def done?(result)
|
|
92
|
+
return true if result == :stop
|
|
93
|
+
|
|
94
|
+
result.is_a?(String) && result.include?(DONE_SENTINEL)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|