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.
- checksums.yaml +4 -4
- data/README.md +182 -11
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +89 -3
- data/lib/rubyn_code/agent/llm_caller.rb +2 -2
- data/lib/rubyn_code/agent/loop.rb +49 -9
- data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
- data/lib/rubyn_code/agent/tool_processor.rb +3 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/checkpoint/hook.rb +26 -0
- data/lib/rubyn_code/checkpoint/manager.rb +109 -0
- data/lib/rubyn_code/chisel/debt.rb +65 -0
- data/lib/rubyn_code/chisel/inspection.rb +93 -0
- data/lib/rubyn_code/chisel.rb +127 -0
- data/lib/rubyn_code/cli/app.rb +2 -2
- data/lib/rubyn_code/cli/commands/agents.rb +31 -0
- data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
- data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
- data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
- data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
- data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
- data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
- data/lib/rubyn_code/cli/commands/context.rb +3 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
- data/lib/rubyn_code/cli/commands/goal.rb +87 -0
- data/lib/rubyn_code/cli/commands/learning.rb +62 -0
- data/lib/rubyn_code/cli/commands/loop.rb +58 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
- data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/loop_runner.rb +98 -0
- data/lib/rubyn_code/cli/mention_expander.rb +92 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +37 -14
- data/lib/rubyn_code/cli/repl_commands.rb +77 -2
- data/lib/rubyn_code/cli/repl_setup.rb +9 -1
- data/lib/rubyn_code/cli/setup.rb +13 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
- data/lib/rubyn_code/cli/version_check.rb +10 -3
- data/lib/rubyn_code/config/defaults.rb +13 -1
- data/lib/rubyn_code/config/schema.json +4 -0
- data/lib/rubyn_code/config/settings.rb +17 -2
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/debug.rb +11 -5
- data/lib/rubyn_code/goal/evaluator.rb +95 -0
- data/lib/rubyn_code/hooks/event_map.rb +56 -0
- data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
- data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
- data/lib/rubyn_code/hooks/response.rb +83 -0
- data/lib/rubyn_code/hooks/runner.rb +61 -3
- data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
- data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +143 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers.rb +17 -2
- data/lib/rubyn_code/ide/protocol.rb +15 -0
- data/lib/rubyn_code/ide/server.rb +39 -1
- data/lib/rubyn_code/index/codebase_index.rb +39 -1
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
- data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
- data/lib/rubyn_code/llm/model_router.rb +2 -2
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
- data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
- data/lib/rubyn_code/megaplan/interview_session.rb +250 -0
- data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
- data/lib/rubyn_code/memory/search.rb +9 -5
- data/lib/rubyn_code/memory/session_persistence.rb +159 -21
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
- data/lib/rubyn_code/skills/registry_client.rb +4 -3
- data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
- data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
- data/lib/rubyn_code/teams/agent_registry.rb +120 -0
- data/lib/rubyn_code/teams/mailbox.rb +99 -10
- data/lib/rubyn_code/teams/manager.rb +83 -5
- data/lib/rubyn_code/teams/teammate.rb +5 -1
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/executor.rb +5 -3
- data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
- data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
- data/lib/rubyn_code/tools/web_fetch.rb +1 -1
- data/lib/rubyn_code/tools/web_search.rb +4 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +53 -2
- data/skills/megaplan/megaplan.md +156 -0
- data/skills/rubyn_self_test.md +322 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +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
|
data/lib/rubyn_code/cli/app.rb
CHANGED
|
@@ -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
|