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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +182 -11
  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/app.rb +2 -2
  17. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  18. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  19. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  20. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  21. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  22. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  23. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  24. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  25. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  26. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  27. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  28. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  29. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  30. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  31. data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
  32. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  33. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  34. data/lib/rubyn_code/cli/first_run.rb +1 -1
  35. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  36. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  37. data/lib/rubyn_code/cli/renderer.rb +3 -2
  38. data/lib/rubyn_code/cli/repl.rb +37 -14
  39. data/lib/rubyn_code/cli/repl_commands.rb +77 -2
  40. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  41. data/lib/rubyn_code/cli/setup.rb +13 -0
  42. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  43. data/lib/rubyn_code/cli/version_check.rb +10 -3
  44. data/lib/rubyn_code/config/defaults.rb +13 -1
  45. data/lib/rubyn_code/config/schema.json +4 -0
  46. data/lib/rubyn_code/config/settings.rb +17 -2
  47. data/lib/rubyn_code/context/manager.rb +29 -12
  48. data/lib/rubyn_code/debug.rb +11 -5
  49. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  50. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  51. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  52. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  53. data/lib/rubyn_code/hooks/response.rb +83 -0
  54. data/lib/rubyn_code/hooks/runner.rb +61 -3
  55. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  56. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  57. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
  58. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
  59. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
  60. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
  61. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  62. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +143 -0
  63. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  64. data/lib/rubyn_code/ide/handlers.rb +17 -2
  65. data/lib/rubyn_code/ide/protocol.rb +15 -0
  66. data/lib/rubyn_code/ide/server.rb +39 -1
  67. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  68. data/lib/rubyn_code/learning/porter.rb +129 -0
  69. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  70. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  71. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  72. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  73. data/lib/rubyn_code/llm/model_router.rb +2 -2
  74. data/lib/rubyn_code/mcp/client.rb +59 -0
  75. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  76. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  77. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  78. data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
  79. data/lib/rubyn_code/megaplan/interview_session.rb +250 -0
  80. data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
  81. data/lib/rubyn_code/memory/search.rb +9 -5
  82. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  83. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  84. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  85. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  86. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  87. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  88. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  89. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  90. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  91. data/lib/rubyn_code/teams/manager.rb +83 -5
  92. data/lib/rubyn_code/teams/teammate.rb +5 -1
  93. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  94. data/lib/rubyn_code/tools/executor.rb +5 -3
  95. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  96. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  97. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  98. data/lib/rubyn_code/tools/web_search.rb +4 -1
  99. data/lib/rubyn_code/version.rb +1 -1
  100. data/lib/rubyn_code.rb +53 -2
  101. data/skills/megaplan/megaplan.md +156 -0
  102. data/skills/rubyn_self_test.md +322 -14
  103. data/skills/self_test/chisel_smoke.rb +84 -0
  104. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  105. metadata +49 -4
@@ -9,7 +9,7 @@ require_relative 'llm_caller'
9
9
 
10
10
  module RubynCode
11
11
  module Agent
12
- class Loop
12
+ class Loop # rubocop:disable Metrics/ClassLength -- core agent loop: LLM calls, tool dispatch, recovery, hooks
13
13
  include SystemPromptBuilder
14
14
  include ResponseParser
15
15
  include ToolProcessor
@@ -18,6 +18,7 @@ module RubynCode
18
18
  include LlmCaller
19
19
 
20
20
  MAX_ITERATIONS = Config::Defaults::MAX_ITERATIONS
21
+ GOAL_MAX_ITERATIONS = Config::Defaults::GOAL_MAX_ITERATIONS
21
22
 
22
23
  # @param opts [Hash] keyword arguments for loop configuration
23
24
  # @option opts [LLM::Client] :llm_client
@@ -39,6 +40,7 @@ module RubynCode
39
40
  assign_dependencies(opts)
40
41
  assign_callbacks(opts)
41
42
  @plan_mode = false
43
+ @static_prompt_sections = nil
42
44
  end
43
45
 
44
46
  # @return [Boolean]
@@ -61,19 +63,36 @@ module RubynCode
61
63
  @skill_ttl&.tick!
62
64
  autoload_triggered_skills(user_input)
63
65
  @conversation.add_user_message(user_input)
66
+ reset_system_prompt_cache!
64
67
  reset_iteration_state
65
68
 
66
- MAX_ITERATIONS.times do |iteration|
69
+ iteration = 0
70
+ loop do
67
71
  result = run_iteration(iteration)
68
72
  return result if result
73
+
74
+ iteration += 1
75
+ break unless keep_iterating?(iteration)
69
76
  end
70
77
 
71
- RubynCode::Debug.warn("Hit MAX_ITERATIONS (#{MAX_ITERATIONS})")
72
- max_iterations_warning
78
+ RubynCode::Debug.warn("Hit iteration limit (#{iteration})")
79
+ max_iterations_warning(iteration)
73
80
  end
74
81
 
75
82
  private
76
83
 
84
+ # Decide whether the loop should run another iteration after `iteration`
85
+ # turns. Normally capped at MAX_ITERATIONS, but while a Stop hook (e.g. an
86
+ # active /goal) is keeping the agent alive we extend up to a hard ceiling
87
+ # — a goal can need more tool turns than a single request. The GoalHook's
88
+ # own max-attempts valve terminates an unsatisfiable goal; the ceiling is
89
+ # only a runaway guard.
90
+ def keep_iterating?(iteration)
91
+ return true if iteration < MAX_ITERATIONS
92
+
93
+ @stop_block_active && iteration < GOAL_MAX_ITERATIONS
94
+ end
95
+
77
96
  def assign_dependencies(opts)
78
97
  assign_required_deps(opts)
79
98
  assign_optional_deps(opts)
@@ -148,6 +167,7 @@ module RubynCode
148
167
  @max_tokens_override = nil
149
168
  @output_recovery_count = 0
150
169
  @task_budget_remaining = nil
170
+ @stop_block_active = false # true while a Stop hook keeps us going
151
171
  end
152
172
 
153
173
  def run_iteration(iteration)
@@ -199,6 +219,13 @@ module RubynCode
199
219
 
200
220
  @conversation.add_assistant_message(response_content(response))
201
221
 
222
+ # Stop hook: a hook may block stopping (e.g. an active /goal). When
223
+ # blocked, the reason is injected as user feedback and the loop keeps
224
+ # iterating instead of returning the final text. While blocked, the
225
+ # loop is allowed to run past MAX_ITERATIONS (see #keep_iterating?).
226
+ @stop_block_active = stop_blocked?(text)
227
+ return nil if @stop_block_active
228
+
202
229
  # Decision-based compaction (topic switch, milestone)
203
230
  @decision_compactor&.check!(@conversation)
204
231
 
@@ -208,6 +235,19 @@ module RubynCode
208
235
  text
209
236
  end
210
237
 
238
+ # Fires the :stop hook. If a hook blocks (returns { block: true }), the
239
+ # reason is appended as a user message so the next iteration acts on it.
240
+ #
241
+ # @return [Boolean] true if stopping was blocked (keep iterating)
242
+ def stop_blocked?(text)
243
+ decision = @hook_runner.fire(:stop, conversation: @conversation, response_text: text)
244
+ return false unless decision.is_a?(Hash) && decision[:block]
245
+
246
+ RubynCode::Debug.agent('Stop blocked by hook — continuing')
247
+ @conversation.add_user_message(decision[:reason])
248
+ true
249
+ end
250
+
211
251
  # Empty LLM response (0 content blocks). Common after dispatching
212
252
  # background_run — the LLM has nothing to say until results arrive.
213
253
  # Wait briefly for jobs, then either continue or accept the empty response.
@@ -243,15 +283,15 @@ module RubynCode
243
283
  # after text responses — mirrors Claude Code's "pause for compaction"
244
284
  # behavior that keeps context manageable in long sessions.
245
285
  def compact_if_needed
246
- return unless @context_manager.needs_compaction?(@conversation.messages)
286
+ return unless @context_manager.needs_compaction?(@conversation)
247
287
 
248
- est = @context_manager.estimated_tokens(@conversation.messages)
288
+ est = @context_manager.estimated_tokens(@conversation)
249
289
  RubynCode::Debug.token(
250
290
  "Context over threshold (#{est}) — running compaction"
251
291
  )
252
292
  @context_manager.check_compaction!(@conversation)
253
293
 
254
- after = @context_manager.estimated_tokens(@conversation.messages)
294
+ after = @context_manager.estimated_tokens(@conversation)
255
295
  RubynCode::Debug.token("Compacted: #{est} → #{after} tokens")
256
296
  rescue StandardError => e
257
297
  RubynCode::Debug.warn("Compaction failed: #{e.message}")
@@ -266,8 +306,8 @@ module RubynCode
266
306
  @max_tokens_override = Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
267
307
  end
268
308
 
269
- def max_iterations_warning
270
- warning = "Reached maximum iteration limit (#{MAX_ITERATIONS}). " \
309
+ def max_iterations_warning(limit = MAX_ITERATIONS)
310
+ warning = "Reached maximum iteration limit (#{limit}). " \
271
311
  'The conversation may be incomplete. Please review the ' \
272
312
  'current state and continue if needed.'
273
313
  @conversation.add_assistant_message([{ type: 'text', text: warning }])
@@ -10,7 +10,7 @@ module RubynCode
10
10
  module SystemPromptBuilder # rubocop:disable Metrics/ModuleLength -- heavily extracted, residual 3 lines over
11
11
  include Prompts
12
12
 
13
- INSTRUCTION_FILES = %w[RUBYN.md CLAUDE.md AGENT.md].freeze
13
+ INSTRUCTION_FILES = %w[RUBYN.md CLAUDE.md AGENTS.md AGENT.md].freeze
14
14
 
15
15
  private
16
16
 
@@ -19,16 +19,49 @@ module RubynCode
19
19
  parts << PLAN_MODE_PROMPT if @plan_mode
20
20
  parts << "Working directory: #{@project_root}" if @project_root
21
21
  append_response_mode(parts)
22
+ static = static_prompt_sections
23
+ parts << static unless static.empty?
24
+ parts.join("\n")
25
+ end
26
+
27
+ # The static sections hit SQLite (memories, instincts) and walk the
28
+ # filesystem (instruction files, profile), so they're assembled once
29
+ # per user turn instead of on every iteration of the tool loop. Only
30
+ # the plan-mode flag and response-mode line vary mid-turn, and those
31
+ # stay in build_system_prompt.
32
+ def static_prompt_sections
33
+ @static_prompt_sections ||= build_static_prompt_sections
34
+ end
35
+
36
+ def build_static_prompt_sections
37
+ parts = []
22
38
  append_project_profile(parts)
23
39
  append_codebase_index(parts)
24
40
  append_memories(parts)
25
41
  append_project_instructions(parts)
26
42
  append_instincts(parts)
27
43
  append_skills(parts)
44
+ append_chisel_ruleset(parts)
28
45
  append_deferred_tools(parts)
29
46
  parts.join("\n")
30
47
  end
31
48
 
49
+ # Chisel's "write the minimum that works" ruleset, injected only when the
50
+ # user has turned it on (chisel_mode != off). Guarded so a config or
51
+ # resolution error never breaks prompt assembly.
52
+ def append_chisel_ruleset(parts)
53
+ section = Chisel.prompt_section
54
+ parts << "\n#{section}" unless section.empty?
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ # Called at the start of each user turn so memory, instruction, and
60
+ # tool changes made between turns show up in the next prompt.
61
+ def reset_system_prompt_cache!
62
+ @static_prompt_sections = nil
63
+ end
64
+
32
65
  def append_response_mode(parts)
33
66
  text = last_user_text
34
67
  return if text.empty?
@@ -182,7 +215,9 @@ module RubynCode
182
215
 
183
216
  db = DB::Connection.instance
184
217
  search = Memory::Search.new(db, project_path: @project_root)
185
- recent = search.recent(limit: 20)
218
+ # touch: false — assembling the prompt is not a memory "access";
219
+ # touching here would issue a SQLite write and inflate access counts.
220
+ recent = search.recent(limit: 20, touch: false)
186
221
  return '' if recent.empty?
187
222
 
188
223
  recent.map { |m| format_memory(m) }.join("\n")
@@ -116,7 +116,9 @@ module RubynCode
116
116
 
117
117
  def execute_tool(tool_name, tool_input)
118
118
  discover_tool(tool_name)
119
- @hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
119
+ pre_decision = @hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
120
+ raise RubynCode::UserDeniedError, pre_decision[:reason] if pre_decision.is_a?(Hash) && pre_decision[:deny]
121
+
120
122
  result = dispatch_tool(tool_name, tool_input)
121
123
  @hook_runner.fire(:post_tool_use, tool_name: tool_name, tool_input: tool_input, result: result)
122
124
  signal_decision_compactor(tool_name, tool_input, result)
@@ -3,7 +3,6 @@
3
3
  require 'securerandom'
4
4
  require 'digest'
5
5
  require 'base64'
6
- require 'faraday'
7
6
  require 'json'
8
7
 
9
8
  module RubynCode
@@ -163,6 +162,7 @@ module RubynCode
163
162
  end
164
163
 
165
164
  def http_client
165
+ require 'faraday'
166
166
  @http_client ||= Faraday.new do |f|
167
167
  f.options.timeout = 30
168
168
  f.options.open_timeout = 10
@@ -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