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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -3
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +89 -3
  5. data/lib/rubyn_code/agent/llm_caller.rb +2 -2
  6. data/lib/rubyn_code/agent/loop.rb +49 -9
  7. data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
  8. data/lib/rubyn_code/agent/tool_processor.rb +3 -1
  9. data/lib/rubyn_code/auth/oauth.rb +1 -1
  10. data/lib/rubyn_code/auth/token_store.rb +49 -4
  11. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  12. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  13. data/lib/rubyn_code/chisel/debt.rb +65 -0
  14. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  15. data/lib/rubyn_code/chisel.rb +127 -0
  16. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  17. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  18. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  19. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  20. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  21. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  23. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  24. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  25. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  26. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  27. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  28. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  29. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  30. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  31. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  32. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  33. data/lib/rubyn_code/cli/first_run.rb +1 -1
  34. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  35. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  36. data/lib/rubyn_code/cli/renderer.rb +3 -2
  37. data/lib/rubyn_code/cli/repl.rb +37 -14
  38. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  39. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  40. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  41. data/lib/rubyn_code/cli/version_check.rb +10 -3
  42. data/lib/rubyn_code/config/defaults.rb +13 -1
  43. data/lib/rubyn_code/config/schema.json +4 -0
  44. data/lib/rubyn_code/config/settings.rb +17 -2
  45. data/lib/rubyn_code/context/manager.rb +29 -12
  46. data/lib/rubyn_code/debug.rb +11 -5
  47. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  48. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  49. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  50. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  51. data/lib/rubyn_code/hooks/response.rb +83 -0
  52. data/lib/rubyn_code/hooks/runner.rb +61 -3
  53. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  54. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  55. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  56. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  57. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  58. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  59. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  60. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  61. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  62. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  63. data/lib/rubyn_code/learning/porter.rb +129 -0
  64. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  65. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  66. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  67. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  68. data/lib/rubyn_code/llm/model_router.rb +2 -2
  69. data/lib/rubyn_code/mcp/client.rb +59 -0
  70. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  71. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  72. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  73. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  74. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  75. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  76. data/lib/rubyn_code/memory/search.rb +9 -5
  77. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  78. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  79. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  80. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  81. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  82. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  83. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  84. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  85. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  86. data/lib/rubyn_code/teams/manager.rb +83 -5
  87. data/lib/rubyn_code/teams/teammate.rb +5 -1
  88. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  89. data/lib/rubyn_code/tools/executor.rb +5 -3
  90. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  91. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  92. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  93. data/lib/rubyn_code/tools/web_search.rb +4 -1
  94. data/lib/rubyn_code/version.rb +1 -1
  95. data/lib/rubyn_code.rb +45 -2
  96. data/skills/rubyn_self_test.md +322 -14
  97. data/skills/self_test/chisel_smoke.rb +84 -0
  98. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  99. 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. Local YAML file (~/.rubyn-code/tokens.yml)
18
- # 3. ANTHROPIC_API_KEY environment variable
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
- load_from_keychain || load_from_file || load_from_env
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
- tokens = self.load
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