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.
- checksums.yaml +4 -4
- data/README.md +137 -4
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +100 -4
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
- data/lib/rubyn_code/agent/llm_caller.rb +7 -3
- data/lib/rubyn_code/agent/loop.rb +106 -12
- data/lib/rubyn_code/agent/response_parser.rb +8 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
- data/lib/rubyn_code/agent/tool_processor.rb +13 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/autonomous/daemon.rb +1 -1
- 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 +30 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
- data/lib/rubyn_code/cli/commands/effort.rb +47 -0
- data/lib/rubyn_code/cli/commands/export.rb +174 -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 +49 -12
- 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/resume.rb +97 -26
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/commands/think.rb +47 -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 +111 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +68 -15
- data/lib/rubyn_code/cli/repl_commands.rb +76 -2
- data/lib/rubyn_code/cli/repl_setup.rb +17 -7
- 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 +14 -1
- data/lib/rubyn_code/config/schema.json +9 -0
- data/lib/rubyn_code/config/settings.rb +19 -2
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- 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 +103 -4
- data/lib/rubyn_code/index/prism_extractor.rb +82 -0
- data/lib/rubyn_code/learning/injector.rb +1 -2
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
- data/lib/rubyn_code/llm/adapters/base.rb +2 -1
- data/lib/rubyn_code/llm/adapters/openai.rb +2 -2
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/client.rb +16 -3
- data/lib/rubyn_code/llm/image_reader.rb +60 -0
- data/lib/rubyn_code/llm/message_builder.rb +21 -1
- data/lib/rubyn_code/llm/model_router.rb +4 -4
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/discovery.rb +93 -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 +8 -3
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/protocols/RUBYN.md +0 -3
- 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/tasks/models.rb +0 -16
- 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 +3 -14
- data/lib/rubyn_code/tools/RUBYN.md +2 -2
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/bash.rb +3 -3
- data/lib/rubyn_code/tools/code_graph.rb +134 -0
- data/lib/rubyn_code/tools/executor.rb +9 -4
- 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/todo_store.rb +55 -0
- data/lib/rubyn_code/tools/todo_write.rb +88 -0
- 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 +57 -9
- data/skills/rubyn_self_test.md +462 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +45 -6
- data/lib/rubyn_code/context/context_budget.rb +0 -183
- data/lib/rubyn_code/context/schema_filter.rb +0 -64
- data/lib/rubyn_code/learning/shortcut.rb +0 -95
- data/lib/rubyn_code/llm/streaming.rb +0 -10
- 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
|