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
@@ -61,6 +61,11 @@ module Woods
61
61
  # in the deprecated auto-detect path. Defaults to {.ollama_reachable?} on
62
62
  # this module. Callers may pass a different probe to facilitate testing
63
63
  # without touching global state.
64
+ # @param stored_config [Woods::ResolvedConfig, nil] a pre-captured stored
65
+ # config to reconcile against INSTEAD of re-reading +artifact+ (M7: the
66
+ # reload transaction captures the config from the dump identity it
67
+ # pinned, so a concurrent promotion cannot change it mid-build). Nil
68
+ # keeps the default behavior of reading the artifact.
64
69
  # @return [Array(Woods::Configuration, Symbol)] tuple of +[config, source]+
65
70
  # where +source+ is one of +:snapshot+, +:host_config+, +:autodetect+,
66
71
  # or +:none+. The config is the same object passed in, possibly mutated.
@@ -72,8 +77,8 @@ module Woods
72
77
  # dimensions disagree.
73
78
  # @raise [Woods::MCP::ConfigMismatch] when stored and live provider
74
79
  # class/model disagree.
75
- def self.resolve(config, artifact:, env: ENV, ollama_probe: nil)
76
- stored = read_stored_config(artifact)
80
+ def self.resolve(config, artifact:, env: ENV, ollama_probe: nil, stored_config: :not_given)
81
+ stored = stored_config == :not_given ? read_stored_config(artifact) : stored_config
77
82
 
78
83
  if stored
79
84
  [apply_stored_config(config, stored, artifact: artifact, env: env), :snapshot]
@@ -86,14 +91,22 @@ module Woods
86
91
  end
87
92
  end
88
93
 
89
- # Read and parse +woods.json+ if it exists.
94
+ # Read and parse the resolved config snapshot, if one exists.
95
+ #
96
+ # Delegates entirely to {IndexArtifact#read_config}, which prefers the
97
+ # config embedded in the promoted dump over the root +woods.json+ (#217
98
+ # / FIX 3 — the two used to be independent atomic writes, so a crash
99
+ # between them could publish a new config against the old dump). Do
100
+ # NOT gate on +artifact.config_path.exist?+ here: a promoted dump can
101
+ # carry its own config with no root +woods.json+ ever having been
102
+ # written, and that pre-check would skip straight past it.
90
103
  #
91
104
  # @param artifact [Woods::IndexArtifact, nil]
92
105
  # @return [Woods::ResolvedConfig, nil]
93
106
  # @raise [Woods::MCP::UnsupportedArtifact] if the file has an unsupported
94
107
  # schema version.
95
108
  def self.read_stored_config(artifact)
96
- return nil unless artifact&.config_path&.exist?
109
+ return nil unless artifact
97
110
 
98
111
  raw = artifact.read_config
99
112
  return nil unless raw
@@ -142,7 +155,8 @@ module Woods
142
155
  # Populate a blank {Woods::Configuration} from a stored {ResolvedConfig}.
143
156
  #
144
157
  # Maps the serialised provider class name back to the +:ollama+ /
145
- # +:openai+ symbol that {Builder} expects, and restores store types.
158
+ # +:openai+ / +:fake+ symbol that {Builder} expects, and restores
159
+ # store types.
146
160
  # Applied when an MCP server starts without a host Rails initializer.
147
161
  #
148
162
  # @param config [Woods::Configuration]
@@ -150,9 +164,11 @@ module Woods
150
164
  # @param artifact [Woods::IndexArtifact]
151
165
  # @return [Woods::Configuration]
152
166
  def self.populate_from_stored(config, stored, artifact:, env: ENV)
167
+ config.output_dir = artifact.output_dir.to_s
153
168
  config.vector_store = stored.stores[:vector_store] || :in_memory
154
169
  config.metadata_store = stored.stores[:metadata_store] || :in_memory
155
170
  config.graph_store = stored.stores[:graph_store] || :in_memory
171
+ restore_store_options(config, stored, artifact: artifact, env: env)
156
172
  provider_sym = provider_symbol(stored.embedding_provider[:class])
157
173
  config.embedding_provider = provider_sym
158
174
 
@@ -170,8 +186,10 @@ module Woods
170
186
  # ArgumentError from OpenAI.new(**opts) wasn't a BootstrapError
171
187
  # and the top-level rescue in exe/woods-mcp wouldn't catch it.
172
188
  if provider_sym == :openai
189
+ # Strip-aware, matching the resolver-default path (:321): a
190
+ # whitespace-only key is not a credential on either path (R1-5).
173
191
  api_key = env.fetch('OPENAI_API_KEY', nil)
174
- if api_key.nil? || api_key.empty?
192
+ if api_key.nil? || api_key.strip.empty?
175
193
  raise Woods::MCP::MissingCredential.new(
176
194
  'woods.json says the index was embedded with OpenAI but OPENAI_API_KEY is unset. ' \
177
195
  'Export the key before starting the MCP server, or re-embed with a different provider.',
@@ -189,6 +207,43 @@ module Woods
189
207
  end
190
208
  private_class_method :populate_from_stored
191
209
 
210
+ def self.restore_store_options(config, stored, artifact:, env:)
211
+ if config.metadata_store == :sqlite
212
+ config.metadata_store_options = { database: artifact.output_dir.join('metadata.sqlite3').to_s }
213
+ end
214
+
215
+ options = stored.store_options.fetch(:vector_store, {}).dup
216
+ case config.vector_store
217
+ when :qdrant
218
+ options.delete(:url)
219
+ options[:url] = env['WOODS_QDRANT_URL'] unless env['WOODS_QDRANT_URL'].to_s.empty?
220
+ if options[:collection].to_s.empty? && !env['WOODS_QDRANT_COLLECTION'].to_s.empty?
221
+ options[:collection] = env['WOODS_QDRANT_COLLECTION']
222
+ end
223
+ options[:api_key] = env['WOODS_QDRANT_API_KEY'] unless env['WOODS_QDRANT_API_KEY'].to_s.empty?
224
+ require_store_setting!(options, :url, 'WOODS_QDRANT_URL', :qdrant)
225
+ require_store_setting!(options, :collection, 'WOODS_QDRANT_COLLECTION', :qdrant)
226
+ when :pgvector
227
+ url = env['WOODS_PG_URL']
228
+ if url.to_s.empty?
229
+ raise ConfigMismatch, 'pgvector index requires WOODS_PG_URL when served outside its host application.'
230
+ end
231
+
232
+ require 'active_record'
233
+ options[:connection] = ActiveRecord::Base.establish_connection(url).connection
234
+ end
235
+ config.vector_store_options = options unless options.empty?
236
+ end
237
+ private_class_method :restore_store_options
238
+
239
+ def self.require_store_setting!(options, key, env_name, adapter)
240
+ return unless options[key].to_s.empty?
241
+
242
+ raise ConfigMismatch,
243
+ "#{adapter} index is missing #{key}; set #{env_name} or re-embed so woods.json records it."
244
+ end
245
+ private_class_method :require_store_setting!
246
+
192
247
  # Convert a fully-qualified provider class name back to the symbol
193
248
  # that {Builder} understands.
194
249
  #
@@ -198,6 +253,7 @@ module Woods
198
253
  case class_name.to_s
199
254
  when /Ollama/ then :ollama
200
255
  when /OpenAI/ then :openai
256
+ when /Fake/ then :fake
201
257
  else class_name.to_s
202
258
  end
203
259
  end
@@ -261,6 +317,10 @@ module Woods
261
317
  def self.autodetect_from_env(config, env:, ollama_probe:)
262
318
  probe = ollama_probe || method(:ollama_reachable?)
263
319
  openai_key = env.fetch('OPENAI_API_KEY', nil)
320
+ # A blank key is an absent key (M9). Truthiness alone wired :openai
321
+ # with api_key: "" — skipping the Ollama probe a missing key gets —
322
+ # and then died at Builder time with a raw ConfigurationError.
323
+ openai_key = nil if openai_key.is_a?(String) && openai_key.strip.empty?
264
324
  if openai_key
265
325
  config.vector_store = :in_memory
266
326
  config.metadata_store = :in_memory
@@ -132,5 +132,65 @@ module Woods
132
132
  'provider unreachable'
133
133
  end
134
134
  end
135
+
136
+ # Raised when an MCP +reload+ transaction cannot complete (M7). Deliberately
137
+ # a sibling of {BootstrapError}, not a child: this describes a runtime
138
+ # reload attempt, not boot state. The transaction is all-or-nothing —
139
+ # when this is raised, the reader caches and the retriever's store bundle
140
+ # were NOT touched and the previous generation is still being served.
141
+ #
142
+ # The exception carries the payload of the reload-phase degraded condition:
143
+ # the generation still being served, and the store component(s) whose
144
+ # refresh failed. The +reload+ tool maps it to a +degraded_index+ tool
145
+ # error with +phase: 'reload'+, and {BootstrapState#record_reload_failure}
146
+ # persists the same shape for +woods_status+.
147
+ #
148
+ # @example Raising from the reload transaction
149
+ # raise Woods::MCP::ReloadDegraded.new(
150
+ # "vector hydration failed: IOError: dump vanished",
151
+ # generation: served_marker.number, stores: [:vector], error: e
152
+ # )
153
+ class ReloadDegraded < Woods::Error
154
+ # @return [Integer] the generation number still being served
155
+ attr_reader :generation
156
+
157
+ # @return [Array<String>] store component(s) whose refresh failed
158
+ # (subset of +vector+, +metadata+, +graph+; all three when the
159
+ # failure is not attributable to one component)
160
+ attr_reader :stores
161
+
162
+ # @return [Exception, nil] the underlying error, when the failure was
163
+ # caused by one
164
+ attr_reader :cause_error
165
+
166
+ # @param message [String]
167
+ # @param generation [Integer] generation still being served
168
+ # @param stores [Array<Symbol, String>] failing store component(s)
169
+ # @param error [Exception, nil] underlying cause
170
+ def initialize(message, generation:, stores:, error: nil)
171
+ super(message)
172
+ @generation = generation
173
+ @stores = Array(stores).map(&:to_s)
174
+ @cause_error = error
175
+ end
176
+ end
177
+
178
+ # The generation recheck inside a reload transaction failed: the on-disk
179
+ # generation moved while candidate stores were being built off-side, so
180
+ # the built bundle is stale. Swapping it would pair reader state from one
181
+ # generation with stores from another. Nothing is swapped; the next
182
+ # +reload+ invocation is the recovery path (it rebuilds candidates
183
+ # against the fresh generation).
184
+ class ReloadGenerationMoved < ReloadDegraded; end
185
+
186
+ # The promoted-dump recheck inside a reload transaction failed. The embed
187
+ # path's commit point ({IndexArtifact#promote}, which flips +dumps/latest+)
188
+ # does not bump the generation file, so an embed-only publication moves
189
+ # the dump identity WITHOUT moving the generation marker — this recheck is
190
+ # the only thing standing between a reload and a bundle whose vector and
191
+ # metadata halves were hydrated from two different dumps. Nothing is
192
+ # swapped; the next +reload+ re-captures and rebuilds against whatever is
193
+ # promoted by then.
194
+ class ReloadDumpMoved < ReloadDegraded; end
135
195
  end
136
196
  end