rubyn-code 0.5.1 → 0.8.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +137 -4
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +100 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  6. data/lib/rubyn_code/agent/llm_caller.rb +7 -3
  7. data/lib/rubyn_code/agent/loop.rb +106 -12
  8. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
  10. data/lib/rubyn_code/agent/tool_processor.rb +13 -1
  11. data/lib/rubyn_code/auth/oauth.rb +1 -1
  12. data/lib/rubyn_code/auth/token_store.rb +49 -4
  13. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  14. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  15. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  16. data/lib/rubyn_code/chisel/debt.rb +65 -0
  17. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  18. data/lib/rubyn_code/chisel.rb +127 -0
  19. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  20. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  21. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  23. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  24. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  25. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  26. data/lib/rubyn_code/cli/commands/context.rb +30 -1
  27. data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
  28. data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
  29. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  30. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  31. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  32. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  33. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  34. data/lib/rubyn_code/cli/commands/mcp.rb +49 -12
  35. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  36. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  37. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  38. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  39. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  40. data/lib/rubyn_code/cli/first_run.rb +1 -1
  41. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  42. data/lib/rubyn_code/cli/mention_expander.rb +111 -0
  43. data/lib/rubyn_code/cli/renderer.rb +3 -2
  44. data/lib/rubyn_code/cli/repl.rb +68 -15
  45. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  46. data/lib/rubyn_code/cli/repl_setup.rb +17 -7
  47. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  48. data/lib/rubyn_code/cli/version_check.rb +10 -3
  49. data/lib/rubyn_code/config/defaults.rb +14 -1
  50. data/lib/rubyn_code/config/schema.json +9 -0
  51. data/lib/rubyn_code/config/settings.rb +19 -2
  52. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  53. data/lib/rubyn_code/context/manager.rb +29 -12
  54. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  55. data/lib/rubyn_code/debug.rb +11 -5
  56. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  57. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  58. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  59. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  60. data/lib/rubyn_code/hooks/response.rb +83 -0
  61. data/lib/rubyn_code/hooks/runner.rb +61 -3
  62. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  63. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  64. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  65. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  66. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  67. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  69. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  70. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  71. data/lib/rubyn_code/index/codebase_index.rb +103 -4
  72. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  73. data/lib/rubyn_code/learning/injector.rb +1 -2
  74. data/lib/rubyn_code/learning/porter.rb +129 -0
  75. data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
  76. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  77. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  78. data/lib/rubyn_code/llm/adapters/openai.rb +2 -2
  79. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  80. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  81. data/lib/rubyn_code/llm/client.rb +16 -3
  82. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  83. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  84. data/lib/rubyn_code/llm/model_router.rb +4 -4
  85. data/lib/rubyn_code/mcp/client.rb +59 -0
  86. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  87. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  88. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  90. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  91. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  92. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  93. data/lib/rubyn_code/memory/search.rb +9 -5
  94. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  95. data/lib/rubyn_code/observability/cost_calculator.rb +8 -3
  96. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  97. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  98. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  99. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  100. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  101. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  102. data/lib/rubyn_code/tasks/models.rb +0 -16
  103. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  104. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  105. data/lib/rubyn_code/teams/manager.rb +83 -5
  106. data/lib/rubyn_code/teams/teammate.rb +3 -14
  107. data/lib/rubyn_code/tools/RUBYN.md +2 -2
  108. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  109. data/lib/rubyn_code/tools/bash.rb +3 -3
  110. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  111. data/lib/rubyn_code/tools/executor.rb +9 -4
  112. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  113. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  114. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  115. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  116. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  117. data/lib/rubyn_code/tools/web_search.rb +4 -1
  118. data/lib/rubyn_code/version.rb +1 -1
  119. data/lib/rubyn_code.rb +57 -9
  120. data/skills/rubyn_self_test.md +462 -14
  121. data/skills/self_test/chisel_smoke.rb +84 -0
  122. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  123. metadata +45 -6
  124. data/lib/rubyn_code/context/context_budget.rb +0 -183
  125. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  126. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  127. data/lib/rubyn_code/llm/streaming.rb +0 -10
  128. data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Chisel
5
+ # Builds the over-engineering audit instruction shared by /chisel-review
6
+ # (scope: :diff) and /chisel-audit (scope: :repo). Both judge by the same
7
+ # decision ladder and exclude the same safety floor, so the two commands
8
+ # can never drift apart — they differ only in what code they look at.
9
+ #
10
+ # Detection is delegated to the agent and its tools (git_diff, bash, grep,
11
+ # read_file); this module only assembles the prompt.
12
+ module Inspection
13
+ SMELLS = <<~SMELLS.strip
14
+ Flag code that skips a rung of the ladder:
15
+ - speculative abstractions, wrappers, or base classes with a single caller
16
+ - reinvented stdlib or already-installed-gem functionality
17
+ - needless indirection, configurability, or options nobody uses
18
+ - dead parameters, unused branches, premature generalization
19
+ - a class where a method would do; a method where one line would do
20
+ SMELLS
21
+
22
+ OUTPUT_CONTRACT = <<~CONTRACT.strip
23
+ Return a ranked deletion/simplification list, most impactful first. For
24
+ each item give:
25
+ - `file:line`
26
+ - what it is (one line)
27
+ - which rung of the ladder it skipped
28
+ - the concrete simpler form (delete it / inline it / replace with stdlib X)
29
+
30
+ If nothing is over-engineered, say so plainly instead of inventing work.
31
+ CONTRACT
32
+
33
+ READ_ONLY_NOTE = 'This is a READ-ONLY review: report the list, do not edit any files.'
34
+
35
+ module_function
36
+
37
+ # @param scope [Symbol] :diff (review changes) or :repo (audit codebase)
38
+ # @param target [String, nil] base ref for :diff (default "main"),
39
+ # or an optional path to scope :repo
40
+ # @return [String] the full instruction to send to the agent
41
+ # @raise [ArgumentError] on an unknown scope
42
+ def prompt(scope:, target: nil)
43
+ [lead_in(scope, target), Chisel::LADDER, SMELLS, OUTPUT_CONTRACT, guardrails]
44
+ .join("\n\n")
45
+ end
46
+
47
+ # Read-only guard + the shared safety floor, reused verbatim from Chisel
48
+ # so the exclusion list can never drift from the always-on ruleset.
49
+ #
50
+ # @return [String]
51
+ def guardrails
52
+ "#{READ_ONLY_NOTE}\n\n#{Chisel::SAFETY_FLOOR}\n" \
53
+ 'Those are never over-engineering — leave them even if they add code.'
54
+ end
55
+
56
+ # @return [String]
57
+ def lead_in(scope, target)
58
+ case scope
59
+ when :diff then diff_lead_in(presence(target) || 'main')
60
+ when :repo then repo_lead_in(presence(target))
61
+ else raise ArgumentError, "unknown Chisel inspection scope: #{scope.inspect}"
62
+ end
63
+ end
64
+
65
+ # @param value [#to_s, nil]
66
+ # @return [String, nil] the trimmed value, or nil if blank
67
+ def presence(value)
68
+ str = value.to_s.strip
69
+ str.empty? ? nil : str
70
+ end
71
+
72
+ def diff_lead_in(base)
73
+ <<~LEAD.strip
74
+ Chisel review — find over-engineering in my current changes.
75
+
76
+ Gather the diff with `git diff #{base}...` plus any uncommitted changes
77
+ (`git diff` and `git diff --staged`). Judge ONLY the added or changed
78
+ lines against the Chisel decision ladder below.
79
+ LEAD
80
+ end
81
+
82
+ def repo_lead_in(path)
83
+ scope_line = path ? "Scope the sweep to `#{path}`." : 'Sweep the whole repository.'
84
+ <<~LEAD.strip
85
+ Chisel audit — find accumulated over-engineering in this codebase.
86
+
87
+ #{scope_line} Use grep and file reads to survey the code, then judge it
88
+ against the Chisel decision ladder below.
89
+ LEAD
90
+ end
91
+ end
92
+ end
93
+ end
@@ -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
@@ -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.
@@ -38,6 +40,33 @@ module RubynCode
38
40
 
39
41
  # @return [Boolean]
40
42
  def plan_mode? = plan_mode
43
+
44
+ # Restrict the tool set for a single agent invocation. The next
45
+ # `send_message` call inside the block sees only the tools in
46
+ # `allowed`; the override is cleared after the call.
47
+ def with_allowed_tools(allowed = nil, &block)
48
+ apply_loop_override(:allowed_tools_override=, allowed, &block)
49
+ end
50
+
51
+ # Apply a one-shot model override for the duration of a block.
52
+ def with_optional_model(model_name = nil, &block)
53
+ apply_loop_override(:model_override=, model_name, &block)
54
+ end
55
+
56
+ private
57
+
58
+ # Set an override on the agent loop (when one is wired in) for the
59
+ # duration of the block, then clear it. Custom commands without
60
+ # a loop (e.g. test doubles) fall through to a plain yield.
61
+ def apply_loop_override(method, value)
62
+ loop = agent_loop
63
+ return yield unless loop.respond_to?(method)
64
+
65
+ loop.public_send(method, value)
66
+ yield
67
+ ensure
68
+ loop&.public_send(method, nil) if loop.respond_to?(method)
69
+ end
41
70
  end
42
71
  end
43
72
  end
@@ -0,0 +1,84 @@
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] argument hint shown next to the name in /help
18
+ attr_reader :argument_hint
19
+ # @return [Array<String>, nil] tool names the command restricts to
20
+ attr_reader :allowed_tools
21
+ # @return [String, nil] model override for this prompt
22
+ attr_reader :model
23
+ # @return [String, nil] originating file path
24
+ attr_reader :source
25
+
26
+ def initialize(name:, description:, body:, # rubocop:disable Metrics/ParameterLists -- explicit kwargs document the frontmatter surface
27
+ source: nil,
28
+ argument_hint: nil, allowed_tools: nil, model: nil)
29
+ @name = name
30
+ @description = description
31
+ @template = CommandTemplate.new(body)
32
+ @argument_hint = argument_hint
33
+ @allowed_tools = allowed_tools
34
+ @model = model
35
+ @source = source
36
+ end
37
+
38
+ def command_name = "/#{@name}"
39
+ def aliases = [].freeze
40
+ def hidden? = false
41
+ def all_names = [command_name].freeze
42
+
43
+ # Render the help label, including [hint] when frontmatter provides one.
44
+ # The argument_hint is expected to be the placeholder text already wrapped
45
+ # in [ ] (e.g. "[env]") — we don't add another set of brackets.
46
+ def help_label
47
+ hint = @argument_hint.to_s.strip
48
+ hint.empty? ? description : "#{description} #{hint}"
49
+ end
50
+
51
+ # @return [Boolean] true if the frontmatter restricted the tool set
52
+ def restricts_tools?
53
+ !@allowed_tools.nil? && !@allowed_tools.empty?
54
+ end
55
+
56
+ # @return [Boolean] true if the frontmatter overrode the model
57
+ def overrides_model?
58
+ !@model.to_s.strip.empty?
59
+ end
60
+
61
+ # @param args [Array<String>]
62
+ # @param ctx [Commands::Context]
63
+ # @return [nil]
64
+ def execute(args, ctx)
65
+ rendered = @template.render(args)
66
+ if restricts_tools?
67
+ ctx.with_allowed_tools(@allowed_tools) do
68
+ ctx.with_optional_model(@model) do
69
+ ctx.send_message(rendered)
70
+ end
71
+ end
72
+ elsif overrides_model?
73
+ ctx.with_optional_model(@model) do
74
+ ctx.send_message(rendered)
75
+ end
76
+ else
77
+ ctx.send_message(rendered)
78
+ end
79
+ nil
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,100 @@
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
+ meta, body = parse(File.read(path))
44
+ description = meta['description'].to_s
45
+ description = "Custom command: /#{name}" if description.strip.empty?
46
+ CustomCommand.new(
47
+ name: name,
48
+ description: description,
49
+ body: body,
50
+ source: path,
51
+ argument_hint: blank_to_nil(stringify(meta['argument_hint'] || meta['argument-hint'])),
52
+ allowed_tools: list_of_tools(meta['allowed_tools'] || meta['allowed-tools']),
53
+ model: blank_to_nil(stringify(meta['model']))
54
+ )
55
+ rescue StandardError => e
56
+ RubynCode::Debug.warn("Failed to load custom command #{path}: #{e.message}")
57
+ nil
58
+ end
59
+
60
+ def parse(content)
61
+ if (match = FRONTMATTER.match(content))
62
+ frontmatter = YAML.safe_load(match[1]) || {}
63
+ [frontmatter, match[2].to_s.strip]
64
+ else
65
+ body = content.to_s.strip
66
+ [{ 'description' => first_line(body) }, body]
67
+ end
68
+ end
69
+
70
+ def blank_to_nil(value)
71
+ value.to_s.strip.empty? ? nil : value
72
+ end
73
+
74
+ def stringify(value)
75
+ return '' if value.nil?
76
+
77
+ # YAML's flow-style `[env]` is parsed as a single-element array.
78
+ # Stringify the array element directly so users see exactly the
79
+ # characters they typed.
80
+ str = value.is_a?(Array) ? value.join : value
81
+ str.to_s.strip
82
+ end
83
+
84
+ def list_of_tools(value)
85
+ return nil if value.nil?
86
+
87
+ arr = value.is_a?(Array) ? value : value.to_s.split(',')
88
+ cleaned = arr.map { |name| name.to_s.strip }.reject(&:empty?)
89
+ cleaned.empty? ? nil : cleaned
90
+ end
91
+
92
+ def first_line(body)
93
+ line = body.lines.first.to_s.strip
94
+ line.sub(/\A#+\s*/, '')[0, 80]
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end