rubyn-code 0.5.0 → 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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +182 -11
  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/app.rb +2 -2
  17. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  18. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  19. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  20. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  21. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  22. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  23. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  24. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  25. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  26. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  27. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  28. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  29. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  30. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  31. data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
  32. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  33. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  34. data/lib/rubyn_code/cli/first_run.rb +1 -1
  35. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  36. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  37. data/lib/rubyn_code/cli/renderer.rb +3 -2
  38. data/lib/rubyn_code/cli/repl.rb +37 -14
  39. data/lib/rubyn_code/cli/repl_commands.rb +77 -2
  40. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  41. data/lib/rubyn_code/cli/setup.rb +13 -0
  42. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  43. data/lib/rubyn_code/cli/version_check.rb +10 -3
  44. data/lib/rubyn_code/config/defaults.rb +13 -1
  45. data/lib/rubyn_code/config/schema.json +4 -0
  46. data/lib/rubyn_code/config/settings.rb +17 -2
  47. data/lib/rubyn_code/context/manager.rb +29 -12
  48. data/lib/rubyn_code/debug.rb +11 -5
  49. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  50. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  51. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  52. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  53. data/lib/rubyn_code/hooks/response.rb +83 -0
  54. data/lib/rubyn_code/hooks/runner.rb +61 -3
  55. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  56. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  57. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
  58. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
  59. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
  60. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
  61. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  62. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +143 -0
  63. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  64. data/lib/rubyn_code/ide/handlers.rb +17 -2
  65. data/lib/rubyn_code/ide/protocol.rb +15 -0
  66. data/lib/rubyn_code/ide/server.rb +39 -1
  67. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  68. data/lib/rubyn_code/learning/porter.rb +129 -0
  69. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  70. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  71. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  72. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  73. data/lib/rubyn_code/llm/model_router.rb +2 -2
  74. data/lib/rubyn_code/mcp/client.rb +59 -0
  75. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  76. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  77. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  78. data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
  79. data/lib/rubyn_code/megaplan/interview_session.rb +250 -0
  80. data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
  81. data/lib/rubyn_code/memory/search.rb +9 -5
  82. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  83. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  84. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  85. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  86. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  87. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  88. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  89. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  90. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  91. data/lib/rubyn_code/teams/manager.rb +83 -5
  92. data/lib/rubyn_code/teams/teammate.rb +5 -1
  93. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  94. data/lib/rubyn_code/tools/executor.rb +5 -3
  95. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  96. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  97. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  98. data/lib/rubyn_code/tools/web_search.rb +4 -1
  99. data/lib/rubyn_code/version.rb +1 -1
  100. data/lib/rubyn_code.rb +53 -2
  101. data/skills/megaplan/megaplan.md +156 -0
  102. data/skills/rubyn_self_test.md +322 -14
  103. data/skills/self_test/chisel_smoke.rb +84 -0
  104. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  105. metadata +49 -4
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ # Chisel is rubyn-code's opt-in "write the minimum that works" enforcement
5
+ # layer. It is OFF by default and only changes the agent's behavior once a
6
+ # user turns it on (via `/chisel full` or the config key `chisel_mode`).
7
+ #
8
+ # The single source of truth for:
9
+ # - which intensity modes exist (off/lite/full/ultra),
10
+ # - which mode is currently active (env override → config → default),
11
+ # - the ruleset text injected into the system prompt at each intensity.
12
+ #
13
+ # The decision ladder is adapted from the open-source `ponytail` plugin and
14
+ # rebuilt natively here; the safety floor (validation, error/data-loss
15
+ # handling, security, accessibility) is never on the chopping block.
16
+ module Chisel
17
+ # On-demand over-engineering audit shared by /chisel-review and /chisel-audit.
18
+ autoload :Inspection, 'rubyn_code/chisel/inspection'
19
+ # Harvests inline `chisel:` deferral markers for /chisel-debt and /chisel-gain.
20
+ autoload :Debt, 'rubyn_code/chisel/debt'
21
+
22
+ MODES = %w[off lite full ultra].freeze
23
+ DEFAULT_MODE = 'off'
24
+ ENV_KEY = 'RUBYN_CHISEL_MODE'
25
+ CONFIG_KEY = 'chisel_mode'
26
+
27
+ # The decision ladder — injected at every non-off intensity.
28
+ LADDER = <<~LADDER.strip
29
+ # Chisel — write the minimum that works
30
+
31
+ Before writing code, stop at the first rung that holds:
32
+ 1. Does this need to exist? If not, don't write it. (YAGNI)
33
+ 2. Does Ruby's stdlib already do it? Use it.
34
+ 3. Does the framework or runtime already do it? Use it.
35
+ 4. Does an already-installed gem do it? Use it — don't add a dependency.
36
+ 5. Is it one line? Write one line.
37
+ 6. Only then: the smallest change that fully solves the task.
38
+ LADDER
39
+
40
+ # Extra guidance layered on at `full` and above.
41
+ FULL_ADDENDUM = <<~FULL.strip
42
+ Prefer editing existing code over adding new files, classes, or layers of
43
+ indirection. Don't introduce an abstraction until a second concrete caller
44
+ exists — three similar lines beat a premature framework.
45
+ FULL
46
+
47
+ # Extra guidance layered on at `ultra` only.
48
+ ULTRA_ADDENDUM = <<~ULTRA.strip
49
+ Be aggressive: question every new method, parameter, option, and file. If
50
+ you can't name the second caller, inline it. When you finish, briefly note
51
+ what you deliberately chose NOT to build.
52
+ ULTRA
53
+
54
+ # The safety floor — appended at every non-off intensity, last so it is
55
+ # never overridden by the "delete more" guidance above it.
56
+ SAFETY_FLOOR = <<~SAFETY.strip
57
+ Lazy, not negligent. Never chisel away: input and trust-boundary
58
+ validation, error and data-loss handling, security, or accessibility.
59
+ SAFETY
60
+
61
+ module_function
62
+
63
+ # @param value [#to_s]
64
+ # @return [Boolean] whether the value is a recognized mode
65
+ def valid?(value)
66
+ MODES.include?(value.to_s)
67
+ end
68
+
69
+ # Resolve the active mode: env override → persisted config → default.
70
+ # Each layer is normalized (trimmed + downcased) the same way the
71
+ # `/chisel` command normalizes its argument, so `RUBYN_CHISEL_MODE=Full`
72
+ # and a hand-edited `chisel_mode: " Full "` both resolve cleanly. Any
73
+ # unrecognized value falls through rather than raising, so a typo can
74
+ # never break a turn.
75
+ #
76
+ # @return [String] one of MODES
77
+ def mode
78
+ normalize(ENV.fetch(ENV_KEY, nil)) ||
79
+ normalize(configured_mode) ||
80
+ DEFAULT_MODE
81
+ end
82
+
83
+ # Trim + downcase a candidate mode, returning it only if recognized,
84
+ # otherwise nil so the caller can fall through to the next layer.
85
+ #
86
+ # @param value [#to_s, nil]
87
+ # @return [String, nil]
88
+ def normalize(value)
89
+ return nil if value.nil?
90
+
91
+ candidate = value.to_s.strip.downcase
92
+ valid?(candidate) ? candidate : nil
93
+ end
94
+
95
+ # @return [Boolean]
96
+ def enabled?
97
+ mode != 'off'
98
+ end
99
+
100
+ # The text Chisel contributes to the system prompt for the active mode.
101
+ #
102
+ # @return [String] "" when off; otherwise ladder + intensity addenda +
103
+ # safety floor.
104
+ def prompt_section
105
+ current = mode
106
+ return '' if current == 'off'
107
+
108
+ parts = [LADDER]
109
+ parts << FULL_ADDENDUM if %w[full ultra].include?(current)
110
+ parts << ULTRA_ADDENDUM if current == 'ultra'
111
+ parts << SAFETY_FLOOR
112
+ parts.join("\n\n")
113
+ end
114
+
115
+ # The persisted mode from config, or nil if unreadable. Isolated so prompt
116
+ # assembly never dies on a malformed config file.
117
+ #
118
+ # @return [String, nil]
119
+ def configured_mode
120
+ # No default needed — chisel_mode lives in Settings::DEFAULT_MAP, so an
121
+ # unset key already resolves to DEFAULT_MODE.
122
+ Config::Settings.new.get(CONFIG_KEY)
123
+ rescue StandardError
124
+ nil
125
+ end
126
+ end
127
+ end
@@ -64,7 +64,7 @@ module RubynCode
64
64
  '--auth' => :auth, '--setup' => :setup
65
65
  }.freeze
66
66
  BOOLEAN_FLAGS = { '--yolo' => :yolo, '--debug' => :debug, '--skip-setup' => :skip_setup, '--ide' => :ide }.freeze
67
- VALUE_FLAGS = { '--permission-mode' => :permission_mode }.freeze
67
+ VALUE_FLAGS = { '--permission-mode' => :permission_mode, '--dir' => :workspace_dir }.freeze
68
68
  DAEMON_INT_FLAGS = { '--max-runs' => :max_runs, '--idle-timeout' => :idle_timeout,
69
69
  '--poll-interval' => :poll_interval }.freeze
70
70
  DAEMON_STR_FLAGS = { '--name' => :agent_name, '--role' => :role }.freeze
@@ -209,7 +209,7 @@ module RubynCode
209
209
 
210
210
  def run_ide
211
211
  mode = resolve_permission_mode
212
- IDE::Server.new(permission_mode: mode).run
212
+ IDE::Server.new(permission_mode: mode, workspace_path: @options[:workspace_dir]).run
213
213
  end
214
214
 
215
215
  def run_daemon
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # `/agents` — list the sub-agent types available to spawn_agent: the
7
+ # built-in explore/worker plus any custom agents defined in
8
+ # .rubyn-code/agents/*.md or ~/.rubyn-code/agents/*.md.
9
+ class Agents < Base
10
+ def self.command_name = '/agents'
11
+ def self.description = 'List available sub-agent types (built-in + custom)'
12
+
13
+ def execute(_args, ctx)
14
+ catalog = RubynCode::SubAgents::Catalog.new(project_root: ctx.project_root)
15
+ ctx.renderer.info('Available sub-agent types:')
16
+ catalog.all.each do |agent|
17
+ tag = agent.custom? ? '(custom)' : '(built-in)'
18
+ access = agent.read_only? ? 'read-only' : 'read/write'
19
+ puts " #{agent.name.ljust(18)} #{tag} [#{access}] — #{agent.description}"
20
+ end
21
+ puts
22
+ ctx.renderer.info('Define your own in .rubyn-code/agents/<name>.md') if catalog.custom_names.empty?
23
+ nil
24
+ rescue StandardError => e
25
+ ctx.renderer.error("Could not list agents: #{e.message}")
26
+ nil
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # Set or report the Chisel intensity. Chisel is the opt-in "write the
7
+ # minimum that works" enforcement layer; it is off by default and only
8
+ # changes the agent's behavior once turned on here.
9
+ class Chisel < Base
10
+ def self.command_name = '/chisel'
11
+ def self.description = 'Set or show Chisel intensity (off|lite|full|ultra)'
12
+
13
+ def execute(args, ctx)
14
+ arg = args.first
15
+ return report(ctx) if arg.nil? || arg.strip.empty?
16
+
17
+ mode = arg.strip.downcase
18
+ return reject(mode, ctx) unless RubynCode::Chisel.valid?(mode)
19
+
20
+ persist(mode, ctx)
21
+ end
22
+
23
+ private
24
+
25
+ def report(ctx)
26
+ current = RubynCode::Chisel.mode
27
+ ctx.renderer.info("Chisel: #{current}")
28
+ ctx.renderer.info("Modes: #{RubynCode::Chisel::MODES.join(' | ')}")
29
+ ctx.renderer.info('Set with: /chisel full')
30
+ end
31
+
32
+ def persist(mode, ctx)
33
+ settings = Config::Settings.new
34
+ settings.set(RubynCode::Chisel::CONFIG_KEY, mode)
35
+ settings.save!
36
+ ctx.renderer.info(confirmation(mode))
37
+ end
38
+
39
+ def confirmation(mode)
40
+ return 'Chisel off — agent behaves normally.' if mode == 'off'
41
+
42
+ "Chisel set to #{mode} — the agent will favor writing the minimum that works."
43
+ end
44
+
45
+ def reject(mode, ctx)
46
+ ctx.renderer.warning("Unknown Chisel mode: #{mode}")
47
+ ctx.renderer.info("Valid modes: #{RubynCode::Chisel::MODES.join(', ')}")
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # `/chisel-audit` — sweep the repo (or a path) for accumulated
7
+ # over-engineering and report a ranked deletion list. Read-only.
8
+ class ChiselAudit < Base
9
+ def self.command_name = '/chisel-audit'
10
+ def self.description = 'Find over-engineering across the repo (/chisel-audit [path])'
11
+
12
+ def execute(args, ctx)
13
+ path = args.first
14
+ ctx.send_message(RubynCode::Chisel::Inspection.prompt(scope: :repo, target: path))
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # `/chisel-debt` — harvest inline `chisel:` deferral markers from the
7
+ # codebase into a ledger view so postponed simplifications aren't lost.
8
+ class ChiselDebt < Base
9
+ def self.command_name = '/chisel-debt'
10
+ def self.description = 'List deferred `chisel:` markers in the codebase'
11
+
12
+ def execute(_args, ctx)
13
+ items = RubynCode::Chisel::Debt.scan(ctx.project_root)
14
+ return ctx.renderer.info('No chisel: debt markers found — clean.') if items.empty?
15
+
16
+ ctx.renderer.info("Chisel debt — #{items.size} deferred #{pluralize(items.size, 'simplification')}:")
17
+ items.each { |item| ctx.renderer.info(" #{item.file}:#{item.line} — #{item.note}") }
18
+ end
19
+
20
+ private
21
+
22
+ def pluralize(count, word)
23
+ count == 1 ? word : "#{word}s"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # `/chisel-gain` — quick read on Chisel's status and what it buys you.
7
+ class ChiselGain < Base
8
+ def self.command_name = '/chisel-gain'
9
+ def self.description = 'Show Chisel status and reference impact'
10
+
11
+ # Measured on a real open-source repo for the approach Chisel is built on;
12
+ # shown as an attributed reference, not fabricated per-user metrics (which
13
+ # rubyn-code does not instrument).
14
+ REFERENCE_IMPACT = 'Reference benchmark for this approach (real FastAPI + React repo): ' \
15
+ '~54% less code, ~20% cheaper, ~27% faster.'
16
+
17
+ def execute(_args, ctx)
18
+ mode = RubynCode::Chisel.mode
19
+ debt = RubynCode::Chisel::Debt.scan(ctx.project_root).size
20
+
21
+ ctx.renderer.info("Chisel mode: #{mode}")
22
+ ctx.renderer.info('Turn it on with /chisel full.') if mode == 'off'
23
+ ctx.renderer.info("Outstanding chisel: debt markers: #{debt}")
24
+ ctx.renderer.info(REFERENCE_IMPACT)
25
+ ctx.renderer.info('Run /chisel-review for concrete cuts in your current diff.')
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -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