woods 1.6.1 → 2.0.0.beta2

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'open3'
5
3
  require 'shellwords'
6
4
 
7
5
  # @see Woods
@@ -11,162 +9,79 @@ module Woods
11
9
  module Console
12
10
  class ConnectionError < Woods::Error; end
13
11
 
14
- # Manages the bridge process connection via Docker exec, direct spawn, or SSH.
15
- #
16
- # Spawns and manages the bridge process, sends JSON-lines requests,
17
- # receives responses. Implements heartbeat (30s) and reconnect with
18
- # exponential backoff (max 5 retries).
19
- #
20
- # @example
21
- # manager = ConnectionManager.new(config: {
22
- # 'mode' => 'direct',
23
- # 'command' => 'bundle exec rails runner bridge.rb'
24
- # })
25
- # manager.connect!
26
- # response = manager.send_request({ 'id' => 'r1', 'tool' => 'status', 'params' => {} })
27
- # manager.disconnect!
12
+ # Resolves and launches an embedded Console MCP process.
28
13
  #
14
+ # The former implementation spoke a private JSON-lines bridge protocol to
15
+ # a scaffold that returned static data. The supported path now forwards the
16
+ # MCP client's stdio directly to the real embedded server by replacing this
17
+ # process with a direct, Docker, or SSH command.
29
18
  class ConnectionManager
30
- MAX_RETRIES = 5
31
- HEARTBEAT_INTERVAL = 30
32
-
33
- # @param config [Hash] Connection configuration
34
- # @option config [String] 'mode' Connection mode: 'docker', 'direct', or 'ssh'
35
- # @option config [String] 'command' Command to run the bridge
36
- # @option config [String] 'container' Docker container name (docker mode)
37
- # @option config [String] 'directory' Working directory (direct mode)
38
- # @option config [String] 'host' SSH host (ssh mode)
39
- # @option config [String] 'user' SSH user (ssh mode)
19
+ DEFAULT_COMMAND = 'bundle exec rake woods:console'
20
+
21
+ # @param config [Hash] Process-launch configuration
22
+ # @option config [String] 'mode' direct, docker, or ssh (default: direct)
23
+ # @option config [String] 'command' Embedded server command
24
+ # @option config [String] 'directory' Working directory for direct mode
25
+ # @option config [String] 'container' Container name for docker mode
26
+ # @option config [String] 'host' SSH host for ssh mode
27
+ # @option config [String] 'user' Optional SSH user
40
28
  def initialize(config:)
41
29
  @config = config
42
- @mode = config['mode'] || 'direct'
43
- @command = config['command'] || 'bundle exec rails runner lib/woods/console/bridge.rb'
44
- @stdin = nil
45
- @stdout = nil
46
- @wait_thread = nil
47
- @retries = 0
48
- @last_heartbeat = nil
30
+ @mode = config.fetch('mode', 'direct')
31
+ @embedded_command = config.fetch('command', DEFAULT_COMMAND)
49
32
  end
50
33
 
51
- # Spawn the bridge process.
34
+ # Return the argv used to launch the embedded MCP server.
52
35
  #
53
- # @return [void]
54
- # @raise [ConnectionError] if the process cannot be started
55
- def connect!
56
- cmd = build_command
57
- if @mode == 'direct' && @config['directory']
58
- Dir.chdir(@config['directory']) do
59
- @stdin, @stdout, @wait_thread = Open3.popen2(*cmd)
60
- end
61
- else
62
- @stdin, @stdout, @wait_thread = Open3.popen2(*cmd)
36
+ # @return [Array<String>]
37
+ # @raise [ConnectionError] when the mode is invalid or incomplete
38
+ def command
39
+ case @mode
40
+ when 'direct' then embedded_argv
41
+ when 'docker' then docker_command
42
+ when 'ssh' then ssh_command
43
+ else raise ConnectionError, "Unknown connection mode: #{@mode}"
63
44
  end
64
- @last_heartbeat = Time.now
65
- @retries = 0
66
- rescue StandardError => e
67
- raise ConnectionError, "Failed to connect (#{@mode}): #{e.message}"
68
45
  end
69
46
 
70
- # Terminate the bridge process.
47
+ # Replace the current process with the embedded MCP server.
71
48
  #
72
- # @return [void]
73
- def disconnect!
74
- @stdin&.close
75
- @stdout&.close
76
- @wait_thread&.value
77
- @stdin = nil
78
- @stdout = nil
79
- @wait_thread = nil
80
- end
81
-
82
- # Send a request to the bridge and read the response.
83
- #
84
- # @param request [Hash] JSON-serializable request hash
85
- # @return [Hash] Parsed response hash
86
- # @raise [ConnectionError] if communication fails after retries
87
- def send_request(request)
88
- ensure_connected!
89
- @stdin.puts(JSON.generate(request))
90
- @stdin.flush
91
- line = @stdout.gets
92
- raise ConnectionError, 'Bridge process closed unexpectedly' unless line
93
-
94
- @last_heartbeat = Time.now
95
- JSON.parse(line)
96
- rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
97
- reconnect_or_raise!(e)
98
- retry
99
- end
100
-
101
- # Check if the bridge process is alive.
49
+ # Process replacement gives the MCP client direct ownership of lifecycle:
50
+ # EOF, INT, and TERM reach the real server without an intermediate process
51
+ # that can hang while waiting for a child.
102
52
  #
103
- # @return [Boolean]
104
- def alive?
105
- return false unless @wait_thread
106
-
107
- @wait_thread.alive?
108
- end
109
-
110
- # Check if a heartbeat is needed (30s since last communication).
111
- #
112
- # @return [Boolean]
113
- def heartbeat_needed?
114
- return false unless @last_heartbeat
115
-
116
- (Time.now - @last_heartbeat) >= HEARTBEAT_INTERVAL
53
+ # @return [void]
54
+ # @raise [ConnectionError] when the process cannot be launched
55
+ def replace_process!
56
+ if @mode == 'direct' && @config['directory']
57
+ Dir.chdir(@config['directory']) { exec(*command) }
58
+ else
59
+ exec(*command)
60
+ end
61
+ rescue SystemCallError, ArgumentError => e
62
+ raise ConnectionError, "Failed to launch embedded Console MCP (#{@mode}): #{e.message}"
117
63
  end
118
64
 
119
65
  private
120
66
 
121
- # Build the shell command based on connection mode.
122
- #
123
- # @return [Array<String>]
124
- def build_command
125
- case @mode
126
- when 'docker' then build_docker_command
127
- when 'ssh' then build_ssh_command
128
- when 'direct' then build_direct_command
129
- else raise ConnectionError, "Unknown connection mode: #{@mode}"
130
- end
67
+ def embedded_argv
68
+ argv = @embedded_command.to_s.shellsplit
69
+ raise ConnectionError, 'Console command must not be empty' if argv.empty?
70
+
71
+ argv
72
+ rescue ArgumentError => e
73
+ raise ConnectionError, "Invalid console command: #{e.message}"
131
74
  end
132
75
 
133
- def build_docker_command
76
+ def docker_command
134
77
  container = @config['container'] || raise(ConnectionError, 'Docker mode requires container name')
135
- ['docker', 'exec', '-i', container] + @command.shellsplit
78
+ ['docker', 'exec', '-i', container] + embedded_argv
136
79
  end
137
80
 
138
- def build_ssh_command
81
+ def ssh_command
139
82
  host = @config['host'] || raise(ConnectionError, 'SSH mode requires host')
140
- user = @config['user']
141
- target = user ? "#{user}@#{host}" : host
142
- ['ssh', target] + @command.shellsplit
143
- end
144
-
145
- def build_direct_command
146
- @command.shellsplit
147
- end
148
-
149
- # Ensure the connection is active.
150
- def ensure_connected!
151
- return if alive?
152
-
153
- connect!
154
- end
155
-
156
- # Attempt reconnection with exponential backoff.
157
- #
158
- # @param error [StandardError] The original error
159
- # @raise [ConnectionError] if max retries exceeded
160
- def reconnect_or_raise!(error)
161
- @retries += 1
162
- if @retries > MAX_RETRIES
163
- raise ConnectionError,
164
- "Connection failed after #{MAX_RETRIES} retries: #{error.message}"
165
- end
166
-
167
- sleep((2**(@retries - 1)) * 0.1)
168
- disconnect!
169
- connect!
83
+ target = @config['user'] ? "#{@config['user']}@#{host}" : host
84
+ ['ssh', target] + embedded_argv
170
85
  end
171
86
  end
172
87
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require_relative 'response_context'
5
+ require_relative 'input_contract'
5
6
 
6
7
  module Woods
7
8
  module Console
@@ -10,8 +11,7 @@ module Woods
10
11
  # Encapsulates everything that happens between MCP's `define_tool` block
11
12
  # firing and the `MCP::Tool::Response` returning to the client:
12
13
  #
13
- # 1. Coerce integer-typed args from strings. MCP clients sometimes send
14
- # `"10"` for an `integer` property — we normalize before dispatch.
14
+ # 1. Strictly parse integer strings and enforce schema bounds.
15
15
  # 2. Run the Layer 1 table gate ({ResponseContext#enforce!}).
16
16
  # 3. Translate the tool args into a bridge/executor request via the
17
17
  # handler proc supplied in the {ToolSpec}.
@@ -31,8 +31,7 @@ module Woods
31
31
  class DispatchPipeline
32
32
  # @param tool_name [String]
33
33
  # @param handler [#call] Maps a Hash of tool args to a bridge request Hash.
34
- # @param integer_keys [Array<Symbol>] Property keys declared as `integer`
35
- # in the tool schema.
34
+ # @param properties [Hash] ToolSpec JSON Schema properties.
36
35
  # @param conn_mgr [#send_request] ConnectionManager or EmbeddedExecutor.
37
36
  # @param ctx [ResponseContext] Bundles the three response-safety layers.
38
37
  # Defaults to the Null context so tests can stub minimally.
@@ -40,11 +39,11 @@ module Woods
40
39
  # @param logger [#warn, nil] Structured logger used for credential-scan
41
40
  # and table-gate telemetry. Failures are swallowed so observability
42
41
  # issues never break a tool response.
43
- def initialize(tool_name:, handler:, integer_keys:, conn_mgr:, # rubocop:disable Metrics/ParameterLists
42
+ def initialize(tool_name:, handler:, properties:, conn_mgr:, # rubocop:disable Metrics/ParameterLists
44
43
  ctx: NullResponseContext.instance, renderer: nil, logger: nil)
45
44
  @tool_name = tool_name
46
45
  @handler = handler
47
- @integer_keys = integer_keys
46
+ @properties = properties
48
47
  @conn_mgr = conn_mgr
49
48
  @ctx = ctx
50
49
  @renderer = renderer
@@ -56,21 +55,48 @@ module Woods
56
55
  # @param args [Hash] Tool arguments (symbol keys from MCP).
57
56
  # @return [MCP::Tool::Response]
58
57
  def call(args)
59
- coerce_integer_args!(args)
58
+ InputContract.normalize!(args, @properties)
60
59
  @ctx.enforce!(args)
61
60
  request = @handler.call(args).transform_keys(&:to_s)
62
61
  send_to_bridge(request)
63
62
  rescue TableGateError => e
64
63
  log_table_gate_rejection(args, e)
65
- error_response(e.message)
66
- rescue SqlValidationError, ForbiddenExpressionError => e
67
- error_response(e.message)
64
+ error_response(scan_early_error(e.message))
65
+ rescue InputContract::ValidationError, SqlValidationError, ForbiddenExpressionError => e
66
+ error_response(scan_early_error(e.message))
67
+ rescue StandardError => e
68
+ # Catch-all for pipeline bugs — a renderer NoMethodError, an encoding
69
+ # oddity, a handler defect (L9). Without it the exception escaped into
70
+ # the `mcp` gem's own handling of a raising tool block, which can echo
71
+ # `Class: message` to the client: a leak channel the executor's
72
+ # responses are already sanitized against. Answer with the same
73
+ # sanitized shape the executor uses (class name only, details logged
74
+ # server-side), still routed through the credential scan so nothing an
75
+ # exception message picked up reaches the client either.
76
+ log_unexpected_error(e)
77
+ error_response(scan_early_error(sanitized_unexpected_error(e)))
68
78
  end
69
79
 
70
80
  private
71
81
 
72
- def coerce_integer_args!(args)
73
- @integer_keys.each { |k| args[k] = args[k].to_i if args[k].is_a?(String) }
82
+ # Client-safe stand-in for an unexpected exception. Names the tool so an
83
+ # agent can retry or report, and nothing else — the class and message
84
+ # go to the log instead.
85
+ def sanitized_unexpected_error(_error)
86
+ "Internal error in #{@tool_name} (details logged server-side)"
87
+ end
88
+
89
+ def log_unexpected_error(error)
90
+ return unless @logger
91
+
92
+ @logger.warn(
93
+ 'console.dispatch.unexpected_error',
94
+ tool: @tool_name,
95
+ error_class: error.class.name,
96
+ message: error.message
97
+ )
98
+ rescue StandardError
99
+ nil
74
100
  end
75
101
 
76
102
  def send_to_bridge(request)
@@ -98,6 +124,10 @@ module Woods
98
124
  scanned
99
125
  end
100
126
 
127
+ def scan_early_error(message)
128
+ scan_for_credentials(message, 'tool' => @tool_name.delete_prefix('console_'))
129
+ end
130
+
101
131
  def log_credential_hits(request, counts)
102
132
  return unless @logger
103
133