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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
286
|
+
return unless @context_manager.needs_compaction?(@conversation)
|
|
247
287
|
|
|
248
|
-
est = @context_manager.estimated_tokens(@conversation
|
|
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
|
|
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 (#{
|
|
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
|
-
|
|
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.
|
|
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
|