rubyn-code 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +120 -3
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +89 -3
- data/lib/rubyn_code/agent/llm_caller.rb +2 -2
- data/lib/rubyn_code/agent/loop.rb +49 -9
- data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
- data/lib/rubyn_code/agent/tool_processor.rb +3 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/checkpoint/hook.rb +26 -0
- data/lib/rubyn_code/checkpoint/manager.rb +109 -0
- data/lib/rubyn_code/chisel/debt.rb +65 -0
- data/lib/rubyn_code/chisel/inspection.rb +93 -0
- data/lib/rubyn_code/chisel.rb +127 -0
- data/lib/rubyn_code/cli/commands/agents.rb +31 -0
- data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
- data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
- data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
- data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
- data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
- data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
- data/lib/rubyn_code/cli/commands/context.rb +3 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
- data/lib/rubyn_code/cli/commands/goal.rb +87 -0
- data/lib/rubyn_code/cli/commands/learning.rb +62 -0
- data/lib/rubyn_code/cli/commands/loop.rb +58 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
- data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/loop_runner.rb +98 -0
- data/lib/rubyn_code/cli/mention_expander.rb +92 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +37 -14
- data/lib/rubyn_code/cli/repl_commands.rb +76 -2
- data/lib/rubyn_code/cli/repl_setup.rb +9 -1
- data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
- data/lib/rubyn_code/cli/version_check.rb +10 -3
- data/lib/rubyn_code/config/defaults.rb +13 -1
- data/lib/rubyn_code/config/schema.json +4 -0
- data/lib/rubyn_code/config/settings.rb +17 -2
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/debug.rb +11 -5
- data/lib/rubyn_code/goal/evaluator.rb +95 -0
- data/lib/rubyn_code/hooks/event_map.rb +56 -0
- data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
- data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
- data/lib/rubyn_code/hooks/response.rb +83 -0
- data/lib/rubyn_code/hooks/runner.rb +61 -3
- data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
- data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +39 -1
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
- data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
- data/lib/rubyn_code/llm/model_router.rb +2 -2
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
- data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
- data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
- data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +9 -5
- data/lib/rubyn_code/memory/session_persistence.rb +159 -21
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
- data/lib/rubyn_code/skills/registry_client.rb +4 -3
- data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
- data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
- data/lib/rubyn_code/teams/agent_registry.rb +120 -0
- data/lib/rubyn_code/teams/mailbox.rb +99 -10
- data/lib/rubyn_code/teams/manager.rb +83 -5
- data/lib/rubyn_code/teams/teammate.rb +5 -1
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/executor.rb +5 -3
- data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
- data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
- data/lib/rubyn_code/tools/web_fetch.rb +1 -1
- data/lib/rubyn_code/tools/web_search.rb +4 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +45 -2
- data/skills/rubyn_self_test.md +322 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +37 -1
|
@@ -11,13 +11,27 @@ module RubynCode
|
|
|
11
11
|
EXPIRY_BUFFER_SECONDS = 300 # 5 minutes
|
|
12
12
|
KEYCHAIN_SERVICE = 'Claude Code-credentials'
|
|
13
13
|
|
|
14
|
+
# Strategy chain: each method returns a token hash or nil.
|
|
15
|
+
# First non-nil result wins. Adding a new auth source is a one-line entry.
|
|
16
|
+
LOAD_STRATEGIES = %i[
|
|
17
|
+
load_from_keychain
|
|
18
|
+
load_from_credentials_file
|
|
19
|
+
load_from_file
|
|
20
|
+
load_from_env
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
14
23
|
class << self
|
|
15
24
|
# Load tokens with fallback chain:
|
|
16
25
|
# 1. macOS Keychain (Claude Code's OAuth token)
|
|
17
|
-
# 2.
|
|
18
|
-
# 3.
|
|
26
|
+
# 2. Claude Code credentials file (~/.claude/.credentials.json)
|
|
27
|
+
# 3. Local YAML file (~/.rubyn-code/tokens.yml)
|
|
28
|
+
# 4. ANTHROPIC_API_KEY environment variable
|
|
19
29
|
def load
|
|
20
|
-
|
|
30
|
+
LOAD_STRATEGIES.each do |strategy|
|
|
31
|
+
result = send(strategy)
|
|
32
|
+
return result if result
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
21
35
|
end
|
|
22
36
|
|
|
23
37
|
# Load API key for a given provider. Anthropic uses the full fallback chain.
|
|
@@ -68,7 +82,12 @@ module RubynCode
|
|
|
68
82
|
end
|
|
69
83
|
|
|
70
84
|
def valid?
|
|
71
|
-
|
|
85
|
+
valid_tokens?(self.load)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Validate an already-loaded token hash without re-reading the
|
|
89
|
+
# keychain — lets callers cache the result of `load`.
|
|
90
|
+
def valid_tokens?(tokens)
|
|
72
91
|
return false unless tokens&.fetch(:access_token, nil)
|
|
73
92
|
return true if tokens[:type] == :api_key
|
|
74
93
|
return true unless tokens[:expires_at]
|
|
@@ -88,6 +107,7 @@ module RubynCode
|
|
|
88
107
|
default
|
|
89
108
|
end
|
|
90
109
|
|
|
110
|
+
# macOS only: read from Keychain Services
|
|
91
111
|
def load_from_keychain
|
|
92
112
|
return nil unless RUBY_PLATFORM.include?('darwin')
|
|
93
113
|
|
|
@@ -102,6 +122,21 @@ module RubynCode
|
|
|
102
122
|
nil
|
|
103
123
|
end
|
|
104
124
|
|
|
125
|
+
# Linux/other: Claude Code stores OAuth in a plain JSON file
|
|
126
|
+
def load_from_credentials_file
|
|
127
|
+
path = Config::Defaults::CLAUDE_CREDENTIALS_FILE
|
|
128
|
+
return nil unless File.exist?(path)
|
|
129
|
+
|
|
130
|
+
warn_insecure_permissions(path)
|
|
131
|
+
|
|
132
|
+
oauth = JSON.parse(File.read(path))['claudeAiOauth']
|
|
133
|
+
return nil unless oauth&.dig('accessToken')
|
|
134
|
+
|
|
135
|
+
build_keychain_tokens(oauth)
|
|
136
|
+
rescue StandardError
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
105
140
|
def build_keychain_tokens(oauth)
|
|
106
141
|
{
|
|
107
142
|
access_token: oauth['accessToken'],
|
|
@@ -137,6 +172,16 @@ module RubynCode
|
|
|
137
172
|
{ access_token: api_key, refresh_token: nil, expires_at: nil, type: :api_key, source: :env }
|
|
138
173
|
end
|
|
139
174
|
|
|
175
|
+
# Warn when credentials file has loose permissions (no system ACLs on Linux)
|
|
176
|
+
def warn_insecure_permissions(path)
|
|
177
|
+
mode = File.stat(path).mode & 0o777
|
|
178
|
+
return if mode == 0o600
|
|
179
|
+
|
|
180
|
+
warn "[rubyn-code] WARNING: #{path} has mode #{format('%04o', mode)}, expected 0600"
|
|
181
|
+
rescue StandardError
|
|
182
|
+
nil # best-effort — don't fail a token load over a stat
|
|
183
|
+
end
|
|
184
|
+
|
|
140
185
|
def write_tokens_file(data)
|
|
141
186
|
File.write(tokens_path, YAML.dump(data))
|
|
142
187
|
File.chmod(0o600, tokens_path)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Checkpoint
|
|
5
|
+
# A :pre_tool_use hook that snapshots a file's original contents into the
|
|
6
|
+
# current checkpoint just before a mutating tool changes it. Only the
|
|
7
|
+
# file-mutating tools are watched; everything else is ignored.
|
|
8
|
+
class Hook
|
|
9
|
+
MUTATING_TOOLS = %w[write_file edit_file].freeze
|
|
10
|
+
|
|
11
|
+
# @param manager [Checkpoint::Manager]
|
|
12
|
+
def initialize(manager:)
|
|
13
|
+
@manager = manager
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [nil] never blocks the tool (returns no deny decision)
|
|
17
|
+
def call(tool_name:, tool_input: {}, **_kwargs)
|
|
18
|
+
return nil unless MUTATING_TOOLS.include?(tool_name.to_s)
|
|
19
|
+
|
|
20
|
+
path = tool_input[:path] || tool_input['path']
|
|
21
|
+
@manager.record_file(path) if path
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
# Checkpoints let a user rewind a session, mirroring Claude Code's /rewind.
|
|
7
|
+
# A checkpoint is taken at the start of each user turn and captures:
|
|
8
|
+
# - the conversation state (so chat can be rolled back), and
|
|
9
|
+
# - the original contents of every file mutated during that turn (so code
|
|
10
|
+
# can be restored).
|
|
11
|
+
#
|
|
12
|
+
# File contents are captured lazily by Checkpoint::Hook on :pre_tool_use,
|
|
13
|
+
# just before a mutating tool runs, so only files that actually change are
|
|
14
|
+
# snapshotted.
|
|
15
|
+
module Checkpoint
|
|
16
|
+
# Marker stored for a path that did not exist when first touched, so a
|
|
17
|
+
# rewind deletes it rather than recreating empty content.
|
|
18
|
+
ABSENT = :absent
|
|
19
|
+
|
|
20
|
+
class Manager
|
|
21
|
+
MAX_CHECKPOINTS = 30
|
|
22
|
+
|
|
23
|
+
def initialize(project_root:)
|
|
24
|
+
@project_root = project_root
|
|
25
|
+
@checkpoints = []
|
|
26
|
+
@seq = 0
|
|
27
|
+
@current = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Open a new checkpoint for a user turn. Captures the conversation as it
|
|
31
|
+
# stands before the agent acts.
|
|
32
|
+
#
|
|
33
|
+
# @param label [String] short description (usually the user's message)
|
|
34
|
+
# @param conversation [Agent::Conversation]
|
|
35
|
+
# @return [Integer] the checkpoint id
|
|
36
|
+
def checkpoint!(label:, conversation:)
|
|
37
|
+
@seq += 1
|
|
38
|
+
@current = {
|
|
39
|
+
id: @seq,
|
|
40
|
+
label: summarize(label),
|
|
41
|
+
messages: Array(conversation.messages).dup,
|
|
42
|
+
files: {}
|
|
43
|
+
}
|
|
44
|
+
@checkpoints << @current
|
|
45
|
+
@checkpoints.shift while @checkpoints.size > MAX_CHECKPOINTS
|
|
46
|
+
@seq
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Record a file's original contents before it is mutated (once per
|
|
50
|
+
# checkpoint per path). No-op when no checkpoint is open.
|
|
51
|
+
#
|
|
52
|
+
# @param path [String] absolute or project-relative path
|
|
53
|
+
# @return [void]
|
|
54
|
+
def record_file(path)
|
|
55
|
+
return unless @current && path
|
|
56
|
+
|
|
57
|
+
abs = File.expand_path(path.to_s, @project_root)
|
|
58
|
+
return if @current[:files].key?(abs)
|
|
59
|
+
|
|
60
|
+
@current[:files][abs] = File.file?(abs) ? File.read(abs) : ABSENT
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
RubynCode::Debug.warn("Checkpoint capture failed for #{path}: #{e.message}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Array<Hash>] {id:, label:, files:} newest last
|
|
66
|
+
def list
|
|
67
|
+
@checkpoints.map { |c| { id: c[:id], label: c[:label], files: c[:files].size } }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def empty? = @checkpoints.empty?
|
|
71
|
+
|
|
72
|
+
def latest_id = @checkpoints.last&.fetch(:id)
|
|
73
|
+
|
|
74
|
+
# Restore a checkpoint. Scope :both (default), :code, or :chat.
|
|
75
|
+
# Checkpoints newer than the restored one are discarded.
|
|
76
|
+
#
|
|
77
|
+
# @return [Hash, nil] summary { id:, files_restored: } or nil if not found
|
|
78
|
+
def restore(id, conversation, scope: :both)
|
|
79
|
+
checkpoint = @checkpoints.find { |c| c[:id] == id }
|
|
80
|
+
return nil unless checkpoint
|
|
81
|
+
|
|
82
|
+
restored_files = restore_files(checkpoint) if %i[both code].include?(scope)
|
|
83
|
+
conversation.replace!(checkpoint[:messages].dup) if %i[both chat].include?(scope)
|
|
84
|
+
|
|
85
|
+
@checkpoints.reject! { |c| c[:id] > id }
|
|
86
|
+
@current = nil
|
|
87
|
+
{ id: id, files_restored: restored_files || 0 }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def restore_files(checkpoint)
|
|
93
|
+
checkpoint[:files].each do |abs, content|
|
|
94
|
+
if content == ABSENT
|
|
95
|
+
FileUtils.rm_f(abs)
|
|
96
|
+
else
|
|
97
|
+
File.write(abs, content)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
checkpoint[:files].size
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def summarize(label)
|
|
104
|
+
text = label.to_s.tr("\n", ' ').strip
|
|
105
|
+
text.length > 60 ? "#{text[0, 57]}…" : text
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Chisel
|
|
5
|
+
# Harvests inline deferral markers from the codebase. A marker is a code
|
|
6
|
+
# comment whose text begins with the lowercase tag "chisel:" and records a
|
|
7
|
+
# simplification you consciously postponed (e.g. a comment reading
|
|
8
|
+
# "chisel: collapse this adapter once a second caller exists").
|
|
9
|
+
#
|
|
10
|
+
# Finding them is a deterministic grep — stdlib does it, so there's no LLM
|
|
11
|
+
# round-trip here (Chisel's own ladder, applied to Chisel).
|
|
12
|
+
module Debt
|
|
13
|
+
Item = Data.define(:file, :line, :note)
|
|
14
|
+
|
|
15
|
+
# The whole line must be a comment whose first token is the lowercase tag:
|
|
16
|
+
# optional indentation, a `#` or `//` leader, then `chisel:`, then the note.
|
|
17
|
+
# Anchoring to line-start means a `chisel:` substring inside a string
|
|
18
|
+
# literal or a trailing code comment is NOT harvested — only a marker on its
|
|
19
|
+
# own comment line is. Case-sensitive on purpose, so a descriptive comment
|
|
20
|
+
# that merely starts with "Chisel:" is not a marker.
|
|
21
|
+
MARKER = %r{\A\s*(?:#|//)\s*chisel:\s*(\S.*)}
|
|
22
|
+
|
|
23
|
+
SCAN_EXTENSIONS = %w[.rb .rake .erb .ru .gemspec].freeze
|
|
24
|
+
SKIP_DIRS = %w[.git node_modules vendor coverage tmp log].freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @param root [String, nil] project root to scan
|
|
29
|
+
# @return [Array<Item>] markers found, in file/line order
|
|
30
|
+
def scan(root)
|
|
31
|
+
return [] unless root
|
|
32
|
+
|
|
33
|
+
base = File.expand_path(root)
|
|
34
|
+
return [] unless Dir.exist?(base)
|
|
35
|
+
|
|
36
|
+
source_files(base).flat_map { |path| scan_file(base, path) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param base [String] expanded project root (no trailing slash)
|
|
40
|
+
# @return [Array<String>] absolute paths of scannable source files
|
|
41
|
+
def source_files(base)
|
|
42
|
+
pattern = File.join(base, '**', "*{#{SCAN_EXTENSIONS.join(',')}}")
|
|
43
|
+
Dir.glob(pattern).reject { |path| skip?(base, path) }.sort
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def skip?(base, path)
|
|
47
|
+
rel = path.delete_prefix("#{base}/")
|
|
48
|
+
SKIP_DIRS.any? { |dir| rel == dir || rel.start_with?("#{dir}/") || rel.include?("/#{dir}/") }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Array<Item>] markers in a single file ([] if it can't be read)
|
|
52
|
+
def scan_file(base, path)
|
|
53
|
+
rel = path.delete_prefix("#{base}/")
|
|
54
|
+
items = []
|
|
55
|
+
File.foreach(path).with_index(1) do |line, number|
|
|
56
|
+
match = MARKER.match(line)
|
|
57
|
+
items << Item.new(file: rel, line: number, note: match[1].strip) if match
|
|
58
|
+
end
|
|
59
|
+
items
|
|
60
|
+
rescue StandardError
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -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
|