woods 2.0.0.beta2 → 2.0.0.beta4

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +339 -1
  3. data/CONTRIBUTING.md +188 -12
  4. data/README.md +93 -174
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +109 -8
  7. data/docs/AGENT_SETUP.md +98 -7
  8. data/docs/BACKEND_MATRIX.md +25 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +267 -16
  11. data/docs/CONSOLE_MCP_SETUP.md +80 -7
  12. data/docs/DOCKER_SETUP.md +22 -3
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +45 -6
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +147 -7
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +276 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +37 -22
  22. data/docs/MCP_WORKTREE_SETUP.md +43 -83
  23. data/docs/NOTION_INTEGRATION.md +13 -0
  24. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  25. data/docs/PUBLISHED_INDEX.md +72 -0
  26. data/docs/README.md +7 -0
  27. data/docs/RETRIEVAL_GUIDE.md +273 -12
  28. data/docs/RUNTIME_TRACING.md +71 -0
  29. data/docs/SOURCE_FRESHNESS.md +143 -0
  30. data/docs/TROUBLESHOOTING.md +129 -18
  31. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  32. data/docs/UPGRADING_TO_2.md +48 -22
  33. data/docs/WATCH_DAEMON.md +277 -67
  34. data/exe/woods-agent-config +6 -0
  35. data/exe/woods-extract +5 -0
  36. data/exe/woods-hook-context +6 -0
  37. data/exe/woods-mcp-start +14 -9
  38. data/lib/generators/woods/pgvector_generator.rb +8 -2
  39. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  40. data/lib/tasks/woods.rake +47 -397
  41. data/lib/woods/agent_configuration/applier.rb +135 -0
  42. data/lib/woods/agent_configuration/cli.rb +101 -0
  43. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  44. data/lib/woods/agent_configuration/document.rb +105 -0
  45. data/lib/woods/agent_configuration/error.rb +7 -0
  46. data/lib/woods/agent_configuration/launcher.rb +75 -0
  47. data/lib/woods/agent_configuration/layout.rb +72 -0
  48. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  49. data/lib/woods/agent_configuration/plan.rb +98 -0
  50. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  51. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  52. data/lib/woods/agent_configuration/planner.rb +63 -0
  53. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  54. data/lib/woods/agent_configuration/preflight.rb +100 -0
  55. data/lib/woods/agent_configuration/recovery.rb +49 -0
  56. data/lib/woods/ast/node.rb +2 -0
  57. data/lib/woods/ast/parser.rb +38 -5
  58. data/lib/woods/builder.rb +21 -5
  59. data/lib/woods/cache/cache_middleware.rb +28 -7
  60. data/lib/woods/cache/cache_store.rb +4 -5
  61. data/lib/woods/change_set.rb +5 -4
  62. data/lib/woods/console/credential_index.rb +20 -2
  63. data/lib/woods/console/credential_scanner.rb +18 -17
  64. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  65. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  66. data/lib/woods/console/embedded_executor.rb +32 -10
  67. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  68. data/lib/woods/console/rack_middleware.rb +22 -13
  69. data/lib/woods/console/server.rb +18 -16
  70. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  71. data/lib/woods/console/sql_table_scanner.rb +47 -7
  72. data/lib/woods/console/sql_validator.rb +49 -9
  73. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  74. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  75. data/lib/woods/dependency_graph.rb +65 -13
  76. data/lib/woods/embedding/corpus.rb +94 -0
  77. data/lib/woods/embedding/indexer.rb +114 -60
  78. data/lib/woods/embedding/openai.rb +17 -6
  79. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  80. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  81. data/lib/woods/export/typed_reader.rb +56 -0
  82. data/lib/woods/extractor.rb +277 -149
  83. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  84. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  85. data/lib/woods/extractors/caching_extractor.rb +3 -1
  86. data/lib/woods/extractors/concern_extractor.rb +64 -6
  87. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  88. data/lib/woods/extractors/controller_extractor.rb +13 -4
  89. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  90. data/lib/woods/extractors/declared_parent.rb +55 -0
  91. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  92. data/lib/woods/extractors/engine_extractor.rb +3 -1
  93. data/lib/woods/extractors/event_extractor.rb +4 -2
  94. data/lib/woods/extractors/factory_extractor.rb +3 -1
  95. data/lib/woods/extractors/graphql_extractor.rb +10 -13
  96. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  97. data/lib/woods/extractors/job_extractor.rb +6 -19
  98. data/lib/woods/extractors/lib_extractor.rb +13 -9
  99. data/lib/woods/extractors/mailer_extractor.rb +26 -15
  100. data/lib/woods/extractors/manager_extractor.rb +3 -1
  101. data/lib/woods/extractors/method_parameters.rb +53 -0
  102. data/lib/woods/extractors/middleware_argument.rb +65 -0
  103. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  104. data/lib/woods/extractors/migration_extractor.rb +3 -1
  105. data/lib/woods/extractors/model_extractor.rb +26 -34
  106. data/lib/woods/extractors/package_extractor.rb +24 -4
  107. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  108. data/lib/woods/extractors/policy_extractor.rb +3 -1
  109. data/lib/woods/extractors/poro_extractor.rb +13 -9
  110. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  111. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  112. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  113. data/lib/woods/extractors/route_extractor.rb +3 -1
  114. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  115. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  116. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  117. data/lib/woods/extractors/service_extractor.rb +3 -1
  118. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  119. data/lib/woods/extractors/shared_utility_methods.rb +48 -19
  120. data/lib/woods/extractors/source_nesting.rb +1 -1
  121. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  122. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  123. data/lib/woods/extractors/validator_extractor.rb +3 -1
  124. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  125. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  126. data/lib/woods/gem_mapper.rb +2 -0
  127. data/lib/woods/git_history.rb +116 -0
  128. data/lib/woods/graph_analyzer.rb +35 -6
  129. data/lib/woods/hooks/context_cli.rb +54 -0
  130. data/lib/woods/hooks/context_event.rb +88 -0
  131. data/lib/woods/hooks/context_hint.rb +73 -0
  132. data/lib/woods/hooks/context_impact.rb +77 -0
  133. data/lib/woods/hooks/context_output.rb +47 -0
  134. data/lib/woods/hooks/context_state.rb +102 -0
  135. data/lib/woods/hooks/refresh.rb +79 -0
  136. data/lib/woods/hooks/rule_projection.rb +78 -0
  137. data/lib/woods/input_rules.rb +19 -0
  138. data/lib/woods/mcp/bearer_auth.rb +22 -13
  139. data/lib/woods/mcp/bootstrapper.rb +79 -4
  140. data/lib/woods/mcp/config_resolver.rb +2 -1
  141. data/lib/woods/mcp/index_reader.rb +334 -162
  142. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  143. data/lib/woods/mcp/origin_guard.rb +17 -9
  144. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  145. data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
  146. data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
  147. data/lib/woods/mcp/search_results.rb +74 -0
  148. data/lib/woods/mcp/server.rb +178 -63
  149. data/lib/woods/mcp/tool_contract.rb +3 -1
  150. data/lib/woods/mcp/tool_response_renderer.rb +41 -0
  151. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  152. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  153. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  154. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  155. data/lib/woods/mcp/traversal_response.rb +22 -0
  156. data/lib/woods/notion/exporter.rb +56 -17
  157. data/lib/woods/obsidian/destination_plan.rb +98 -0
  158. data/lib/woods/obsidian/name_mapper.rb +19 -3
  159. data/lib/woods/obsidian/note_builder.rb +19 -10
  160. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  161. data/lib/woods/operator/pipeline_guard.rb +18 -13
  162. data/lib/woods/path_dispatcher.rb +13 -6
  163. data/lib/woods/payload_store.rb +27 -26
  164. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  165. data/lib/woods/published_index.rb +2 -2
  166. data/lib/woods/railtie.rb +3 -3
  167. data/lib/woods/railtie_support.rb +12 -12
  168. data/lib/woods/rake_helpers.rb +382 -0
  169. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  170. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  171. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  172. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  173. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  174. data/lib/woods/resilience/index_validator.rb +112 -23
  175. data/lib/woods/retrieval/context_assembler.rb +50 -15
  176. data/lib/woods/retrieval/lexical_assembler.rb +84 -0
  177. data/lib/woods/retrieval/lexical_index.rb +120 -0
  178. data/lib/woods/retrieval/ranker.rb +4 -2
  179. data/lib/woods/retrieval/scope.rb +108 -0
  180. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  181. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  182. data/lib/woods/retrieval/search_executor.rb +86 -27
  183. data/lib/woods/retrieval/source_evidence.rb +200 -0
  184. data/lib/woods/retriever.rb +98 -22
  185. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  186. data/lib/woods/session_tracer/file_store.rb +6 -1
  187. data/lib/woods/session_tracer/middleware.rb +10 -12
  188. data/lib/woods/session_tracer/redis_store.rb +22 -6
  189. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  190. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  191. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  192. data/lib/woods/source_inputs/consumer_errors.rb +31 -0
  193. data/lib/woods/source_inputs/handoff.rb +102 -0
  194. data/lib/woods/source_inputs/launcher.rb +157 -0
  195. data/lib/woods/source_inputs/manifest.rb +124 -0
  196. data/lib/woods/source_inputs/private_key.rb +55 -0
  197. data/lib/woods/source_inputs/scanner.rb +171 -0
  198. data/lib/woods/source_inputs/scopes.rb +71 -0
  199. data/lib/woods/source_inputs/session.rb +214 -0
  200. data/lib/woods/source_inputs/status.rb +84 -0
  201. data/lib/woods/source_inputs/verifier.rb +107 -0
  202. data/lib/woods/storage/metadata_store.rb +25 -25
  203. data/lib/woods/storage/pgvector.rb +35 -10
  204. data/lib/woods/storage/qdrant.rb +17 -7
  205. data/lib/woods/storage/vector_store.rb +18 -6
  206. data/lib/woods/tasks.rb +3 -2
  207. data/lib/woods/temporal/json_snapshot_store.rb +58 -9
  208. data/lib/woods/unblocked/exporter.rb +59 -70
  209. data/lib/woods/version.rb +1 -1
  210. data/lib/woods/watch/boot_snapshot.rb +52 -0
  211. data/lib/woods/watch/daemon.rb +154 -32
  212. data/lib/woods/watch/listen_watcher.rb +4 -0
  213. data/lib/woods/watch/polling_watcher.rb +5 -1
  214. data/lib/woods/watch/status.rb +20 -15
  215. data/lib/woods/watch/tree_scan.rb +21 -13
  216. data/lib/woods/watch/watcher.rb +4 -1
  217. data/lib/woods.rb +50 -11
  218. data/plugin/.claude-plugin/plugin.json +1 -1
  219. data/plugin/hooks/adapters/normalize.jq +15 -0
  220. data/plugin/hooks/adapters/normalize.rb +63 -0
  221. data/plugin/hooks/hooks.json +20 -0
  222. data/plugin/hooks/woods-context.sh +50 -0
  223. data/plugin/hooks/woods-input-rules.sh +159 -0
  224. data/plugin/hooks/woods-opencode.mjs +65 -0
  225. data/plugin/hooks/woods-post-edit.sh +2 -225
  226. data/plugin/hooks/woods-refresh.sh +260 -0
  227. data/plugin/hooks/woods-session-start.sh +47 -55
  228. data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
  229. data/plugin/skills/woods-diagnose/SKILL.md +319 -1
  230. data/plugin/skills/woods-investigate/SKILL.md +145 -0
  231. data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
  232. data/plugin/skills/woods-setup/SKILL.md +110 -6
  233. metadata +87 -5
@@ -108,8 +108,9 @@ Columns:
108
108
  | `output_dir` | Pathname/String | `Rails.root.join('tmp/woods')` | user-settable | Directory where extracted data is written |
109
109
  | `extractors` | Array<Symbol> | `[:models, :controllers, :services, ...]` | accepted, not implemented | Does not select which extractors run. See [Extractors](#extractors) below. |
110
110
  | `pretty_json` | Boolean | `true` | user-settable | Format extracted JSON with indentation |
111
- | `max_context_tokens` | Integer | `8000` | user-settable | Maximum tokens for retrieval context windows |
112
- | `similarity_threshold` | Float | `0.7` | user-settable | Minimum similarity score (0.0-1.0) for retrieval results |
111
+ | `retrieval_mode` | Symbol | `:semantic` | user-settable | `:semantic` uses configured embeddings; explicit `:lexical` ranks published text without a provider/vector store. See [retrieval modes](RETRIEVAL_GUIDE.md#embedding-free-lexical-retrieval). |
112
+ | `max_context_tokens` | Integer | `8000` | user-settable | Default context-assembly token budget captured when a retriever is built; per-call budget overrides it |
113
+ | `similarity_threshold` | Float | `0.7` | deprecated, inert | Accepted for compatibility; setting it warns. It does not filter or change retrieval ranking |
113
114
  | `context_format` | Symbol | `:markdown` | user-settable | Output format for retrieval: `:claude`, `:markdown`, `:plain`, `:json` |
114
115
  | `include_framework_sources` | Boolean | `true` | user-settable | Extract Rails and gem source code |
115
116
  | `concurrent_extraction` | Boolean | `false` | user-settable | Enable parallel extraction (experimental) |
@@ -135,6 +136,17 @@ config.embedding_options = {
135
136
  }
136
137
  ```
137
138
 
139
+ OpenAI embedding batches are sent in slices of at most 36 texts, preserving
140
+ input order. For inputs within the API's 8,192-token per-text limit, this stays
141
+ below both the 2,048-input limit and the 300,000-token total request limit.
142
+ See the [OpenAI embedding request contract](https://developers.openai.com/api/reference/resources/embeddings/methods/create).
143
+ Woods retains its conservative 8,191-token chunking ceiling; slicing does not
144
+ make an individually oversized text valid. This bound avoids relying on token
145
+ estimates and adds HTTP requests for batches containing many short chunks.
146
+ All slices must validate, including consistent vector dimensions, before the
147
+ provider returns any vectors for the batch. Ollama and custom providers keep
148
+ their existing batching behavior.
149
+
138
150
  ### Ollama embeddings
139
151
 
140
152
  ```ruby
@@ -196,6 +208,25 @@ config.vector_store_options = {
196
208
  }
197
209
  ```
198
210
 
211
+ Woods uses an HNSW index over pgvector's `vector` representation, which supports
212
+ **1–2,000 dimensions** ([pgvector's HNSW limits](https://github.com/pgvector/pgvector#hnsw)).
213
+ Unreleased after `2.0.0.beta3`: the adapter rejects wider dimensions before any
214
+ SQL, and `woods:pgvector` rejects invalid widths before writing a migration.
215
+ There is no automatic vector truncation or half-precision conversion.
216
+
217
+ The default `text-embedding-3-large` output is 3,072 dimensions. With pgvector,
218
+ explicitly request a supported provider output width, for example:
219
+
220
+ ```ruby
221
+ config.embedding_model = 'text-embedding-3-large'
222
+ config.embedding_options = { dimensions: 1536 }
223
+ ```
224
+
225
+ Keep the provider, `vector_store_options[:dimensions]` (when set), and generated
226
+ migration width equal. Changing a stored width requires a compatible new table
227
+ or an intentional index rebuild; changing the setting does not resize old data.
228
+ Use another backend if you need the full 3,072-dimensional output.
229
+
199
230
  Requires the pgvector extension. Run the generator to create migrations:
200
231
 
201
232
  ```bash
@@ -224,6 +255,15 @@ config.metadata_store_options = {
224
255
  }
225
256
  ```
226
257
 
258
+ Without an explicit `database` option, SQLite uses
259
+ `<output_dir>/metadata.sqlite3`. For `woods:embed` and
260
+ `woods:embed_incremental`, `WOODS_OUTPUT` overrides that directory together
261
+ with the index and embedding dumps. An explicit `database` path (including
262
+ `:memory:`) still takes precedence, so configure a separate path for each
263
+ worktree when overriding it. Existing databases at the old configured output
264
+ path are not moved or deleted; run `woods:embed` for the selected index after
265
+ upgrading to populate its default metadata database.
266
+
227
267
  Requires the `sqlite3` gem in your host bundle. Rails apps backed by
228
268
  MySQL or PostgreSQL won't have it by default, selecting `:sqlite`
229
269
  without it raises `Woods::ConfigurationError` with install
@@ -236,10 +276,18 @@ unless cross-process metadata persistence matters.
236
276
  config.metadata_store = :in_memory
237
277
  ```
238
278
 
239
- Pure-Ruby hash-backed store. No external dependencies, no persistence, vectors and metadata both live in the building process and die with
240
- it. The `_index.json` manifest under `output_dir` is the durable
241
- metadata for the index MCP server, so this is a reasonable default
242
- for hosts that don't bundle `sqlite3`.
279
+ Pure-Ruby hash-backed store with no external dependencies. For local vector
280
+ presets, embedding runs persist it as `metadata.msgpack` alongside `vectors.bin`
281
+ in the promoted dump; the index MCP server loads that snapshot at startup or
282
+ reload. Incremental embedding publishes changes to paths, dependencies, and
283
+ other unit metadata even when unchanged source needs no new embedding. A run
284
+ with no content or metadata changes keeps the existing dump and retention
285
+ window. Unreleased after `2.0.0.beta3`: a full `Indexer#index_all` run replaces
286
+ the published corpus even when a custom caller reuses in-memory vector and
287
+ metadata stores. Deleted units, including metadata-only records, are removed;
288
+ an empty full rebuild publishes an empty dump. Failed embedding leaves the
289
+ previous promoted dump and checkpoint intact. Incremental purge guards remain
290
+ unchanged. This is a reasonable default for hosts that don't bundle `sqlite3`.
243
291
 
244
292
  ## Retrieval cache options
245
293
 
@@ -278,6 +326,13 @@ overrides the wrapper defaults for `:embeddings` (24 hours) and `:context`
278
326
  (15 minutes). `:memory` accepts `max_entries` (default 500); it ignores
279
327
  `default_ttl` because each wrapper write supplies its domain TTL.
280
328
 
329
+ `Woods::Cache.cache_key` length-prefixes every component, including a single
330
+ component, so different argument counts cannot share a response. Existing
331
+ multi-component keys used by Woods' wrappers remain unchanged. Custom callers
332
+ using single-component keys must clear their affected persistent cache domain
333
+ when upgrading, since older unprefixed entries can alias the new encoding;
334
+ subsequent calls refill it normally. Namespace clearing still covers both formats.
335
+
281
336
  ## Deployment shapes
282
337
 
283
338
  Woods supports three deployment shapes, pick the preset that matches yours.
@@ -304,7 +359,7 @@ The embed run writes `woods.json` + `dumps/<ISO8601>/vectors.bin` + `metadata.ms
304
359
 
305
360
  Requirements:
306
361
  - `output_dir` must be set and readable by both the embed process and the MCP server.
307
- - The MCP server must know the same `output_dir` (pass via `woods-mcp <DIR>` or set `WOODS_DIR`).
362
+ - The MCP server must know the same `output_dir` (pass via `woods-mcp <DIR>` or set `WOODS_DIR`; see MCP path precedence below).
308
363
 
309
364
  ## Presets
310
365
 
@@ -358,12 +413,56 @@ end
358
413
  | `extract_navigation_edges` | Boolean | `true` | Extract `link_to`, `redirect_to`, and `form_action` navigation edges from views and controllers |
359
414
  | `enable_snapshots` | Boolean | `false` | Enable temporal snapshots. Woods automatically migrates its internal output-directory SQLite store; if SQLite is unavailable, it uses the JSON snapshot store. No Rails migration is required. |
360
415
  | `volatile_dependency_ratio` | Float | `3.0` | A dependency whose commit count (last 365 days) exceeds the dependent's by this ratio appears in the `volatile_dependencies` report (top 20, ranked by PageRank). Must be greater than 1. Report only, never a gate. |
416
+ | `volatile_dependency_limit_per_target` | Integer or `nil` | `nil` | Optional maximum report edges per dependency (type and identifier), applied after ranking and before the global top 20. Positive integers only; `nil` leaves the default report unchanged. Distinct relationships consume separate slots. |
361
417
  | `graph_cycle_limit` | Integer or `nil` | `500` | How many distinct cycles `GraphAnalyzer` enumerates before it stops. Cycle detection finds one cycle per DFS back-edge, so a dense graph has tens of thousands of them and enumerating every one is the largest single cost of the analysis that runs on every extraction. Set to `nil` for exhaustive enumeration. |
362
418
  | `graph_cycle_max_length` | Integer or `nil` | `50` | The longest cycle recorded, in distinct nodes. A back-edge deep in the DFS closes a cycle as long as the path, which on a large graph is thousands of nodes: unreadable as a report and expensive to canonicalize. Set to `nil` to record a cycle of any length. |
363
419
 
364
420
  | `incremental_blast_radius_depth` | Integer or `nil` | `nil` | How many reverse hops an incremental run walks from a changed file before it stops re-extracting dependents. `nil` keeps the unbounded transitive closure. See the note below before setting it. |
365
421
  | `durable_payload_writes` | Boolean | `false` | Force an `fsync` on every payload file as it is written, on top of the single flush every publish already performs. See the note below before setting it. |
366
422
 
423
+ **Tuning volatile dependency reports.** Start with
424
+ `graph_analysis.json`'s `stats.volatile_dependency_count`: it counts every
425
+ qualifying edge before either cap, while the array normally keeps only 20.
426
+ Raise `volatile_dependency_ratio` until the remaining candidates are useful
427
+ for your application. A measured 7k-unit app had 544 qualifying edges at 3.0;
428
+ 8–10 was a useful ratio there, not a universal recommendation. Commit counts
429
+ cover the last 365 days and need complete git history; missing git data is not
430
+ proof of stability.
431
+
432
+ If one hot dependency still fills the list, set
433
+ `volatile_dependency_limit_per_target` to a small positive integer such as 3.
434
+ Each dependency keeps its highest-ranked edges before the global limit applies,
435
+ allowing other dependencies into the report. Different relationship labels
436
+ remain separate edges and consume separate slots. With this option enabled,
437
+ `stats.volatile_dependencies_limit_per_target` records the setting and
438
+ `stats.volatile_dependency_reported_count` counts the final persisted array;
439
+ `stats.volatile_dependency_count` still counts all qualifying edges. The extra
440
+ stats are absent at the default `nil`. This limits the report, not the work of
441
+ finding qualifying edges. Run extraction again after changing either setting;
442
+ MCP reads the published report. These findings remain informational, never a
443
+ release or architecture gate.
444
+
445
+ When the JSON snapshot fallback is in use, malformed JSON, invalid snapshot
446
+ shapes, and files that cannot be read (including concurrent retention removals)
447
+ are warned about and treated as absent. A snapshot needs a hexadecimal string
448
+ `git_sha` matching its filename; `extracted_at` may be a string, null, or omitted.
449
+ When present and non-null, `units` must be an object whose records are objects.
450
+ A malformed record invalidates the entire snapshot, rather than exposing partial
451
+ history. Legacy bare identifier keys, omitted/null unit collections, and optional per-unit hash
452
+ fields remain supported; timestamp strings are not restricted to a new format.
453
+ Unit history limits count matching unit records, not the most recent snapshots
454
+ searched (JSON fallback correction unreleased after `2.0.0.beta3`). A unit
455
+ missing from newer snapshots can still have retained history.
456
+ Snapshot lists and unit history omit unusable files; direct lookup returns no
457
+ snapshot, and a diff with an unavailable snapshot returns empty added, modified,
458
+ and deleted lists. An empty diff in this case is not proof that nothing changed.
459
+ New captures compare against the latest usable snapshot. Unusable SHA-named files
460
+ still count toward retention and are pruned first when the limit is exceeded;
461
+ reading alone does not delete them. Valid legacy snapshots with null or omitted
462
+ timestamps are retained ahead of corrupt files, then treated as oldest among
463
+ usable snapshots. Direct lookup and diff still reject invalid
464
+ caller-supplied SHA paths with an argument error.
465
+
367
466
  `incremental_blast_radius_depth` is unbounded by default because a unit two hops
368
467
  out really can have content that depends on the changed file. An STI grandchild
369
468
  (`SportsCar < Car < Vehicle`) inherits its grandparent's associations,
@@ -408,11 +507,66 @@ require 'woods/session_tracer/file_store' # the stores are not autoloaded
408
507
 
409
508
  config.session_tracer_enabled = true
410
509
  config.session_store = Woods::SessionTracer::FileStore.new(
411
- Rails.root.join('tmp/session_traces')
510
+ base_dir: Rails.root.join('tmp/session_traces')
412
511
  )
413
512
  config.session_exclude_paths = ['/health', '/metrics', '/assets']
414
513
  ```
415
514
 
515
+ ### File session retention
516
+
517
+ `FileStore` accepts `ttl:` in seconds (default `nil`, expiration disabled),
518
+ `max_sessions:` (default `1000`), and `max_requests_per_session:` (default `1000`).
519
+ TTL expires a file when the store clock reaches its modification time plus the
520
+ TTL. Recording after expiry starts a fresh history; expired events are discarded
521
+ before appending or migrating legacy filenames, under the same store lock.
522
+ When legacy and encoded files coexist, each expires independently before any
523
+ surviving histories are merged. Clearing a session is idempotent for supported
524
+ IDs, including Unicode and punctuation, and removes both filename formats when
525
+ applicable.
526
+
527
+ ### Redis session index compatibility
528
+
529
+ `RedisStore` lists and clears both legacy SET indexes and recency ZSET indexes.
530
+ Listing removes expired members and orders summaries by their last request.
531
+ Reads and clears preserve a legacy SET, so upgrading only readers does not
532
+ break older writers. Type checks and index operations run atomically in Redis
533
+ and tolerate a concurrent writer converting the index.
534
+
535
+ The first `record` from a newer writer converts the SET to a ZSET atomically.
536
+ Upgrade writers together: older SET writers cannot write after that conversion.
537
+ Migrated members receive score zero, so they evict in lexical order at the
538
+ retention limit until recorded again; new records use their request timestamp.
539
+ Session list contents and TTLs are preserved by the index conversion.
540
+
541
+ ### Solid Cache session retention and compatibility
542
+
543
+ `Woods::SessionTracer::SolidCacheStore` accepts a direct `SolidCache::Store`.
544
+ Its coordination layer uses private Solid Cache APIs for uncached, per-key
545
+ routing and atomic ownership. Missing APIs raise
546
+ `Woods::SessionTracer::SolidCacheCoordination::BackendError`; a gem's version
547
+ constraint alone does not establish compatibility. The live contract suite
548
+ validates SQLite, PostgreSQL, and MySQL (including MySQL's insert path without
549
+ `RETURNING`). See [the version-validation procedure](../CONTRIBUTING.md#solid-cache-session-compatibility)
550
+ before upgrading Solid Cache.
551
+
552
+ Session traces are best-effort diagnostic data. The slot directory and record
553
+ rings are bounded by `max_sessions` and `max_requests_per_session`, but the
554
+ following crash/eviction limits remain:
555
+
556
+ - A crashed admission can strand an active-session mapping outside the directory.
557
+ These mappings can accumulate across distinct session IDs, with a bounded
558
+ amount per ID. Recording that session again reclaims its mapping; the normal
559
+ directory bound is not a hard bound on these stranded keys.
560
+ - If the backend evicts a slot counter while deep crash-orphan records remain
561
+ in an unoccupied slot, new records can be silently dropped until the counter
562
+ advances past them. This loss is bounded and self-healing, but the affected
563
+ requests are not recovered.
564
+ - Losing the epoch key fences every live session. Old traces become unreadable,
565
+ and each session is admitted again on its next record. This deliberately
566
+ favors privacy over retention so previously cleared data stays cleared.
567
+
568
+ Do not use this store as an audit log or the only record of a request.
569
+
416
570
  ## Gem indexing
417
571
 
418
572
  `config.add_gem` is accepted for forward compatibility but **not implemented**: nothing in the
@@ -525,8 +679,9 @@ deployment guide including defense layers.
525
679
 
526
680
  | Key | Type | Default | Description |
527
681
  |---|---|---|---|
528
- | `console_mcp_enabled` | Boolean | `false` | Master switch. When `false`, the Railtie does not mount the Console MCP middleware. |
529
- | `console_mcp_token` | String | `ENV['WOODS_CONSOLE_MCP_TOKEN']` or `nil` | Bearer token required on every Console HTTP request. **Required in production**: the Railtie raises `Woods::ConfigurationError` when `console_mcp_enabled` is true but no token is set. In non-production a missing token warns at boot and every Console request fails closed with `401 Unauthorized`. A configured token shorter than 32 characters raises `Woods::ConfigurationError` at boot in every environment. Generate with `SecureRandom.hex(32)`. |
682
+ | `console_mcp_enabled` | Boolean | `false` | Master switch. When `false`, stdio exits and the mounted Console middleware passes requests through to Rails. |
683
+ | `console_mcp_http_enabled` | Boolean | `true` | HTTP transport switch; effective only while the master switch is on. Set `false` for stdio-only use without HTTP token validation or an active HTTP endpoint. Read at request time. |
684
+ | `console_mcp_token` | String | `ENV['WOODS_CONSOLE_MCP_TOKEN']` or `nil` | Bearer token required on every enabled Console HTTP request. With both Console flags enabled, production boot raises on a missing token; other environments warn and requests fail closed with 401. A configured token shorter than 32 characters raises at boot while HTTP is enabled. Explicit stdio-only configurations skip HTTP token validation. Generate with `SecureRandom.hex(32)`. |
530
685
  | `console_mcp_allowed_origins` | Array\<String\> | `%w[http://localhost http://127.0.0.1 http://[::1]]` | `OriginGuard` allowlist. Port is stripped before comparison, so `http://localhost` matches any localhost port. Override for tunneled / internal-dashboard access. |
531
686
  | `console_mcp_path` | String | `/mcp/console` | URL path the Rack middleware responds on. |
532
687
  | `console_embedded_read_tools` | Boolean | `false` | Register `console_sql` and `console_query` in supported stdio and Rack modes. |
@@ -547,13 +702,15 @@ These variables are read by the gem and its MCP servers at runtime. They complem
547
702
 
548
703
  | Variable | Default | Purpose |
549
704
  |----------|---------|---------|
550
- | `WOODS_DIR` | `Dir.pwd` | Path to the extraction output directory. |
551
- | `WOODS_REQUIRE_INDEX` | unset | Set to `"1"` to fail closed: the server refuses to boot (raises `MissingArtifact`) unless a real index (`woods.json`) is present. By default an extract-only host boots in pattern/structural mode without it. |
705
+ | `WOODS_RETRIEVAL_MODE` | `semantic` | Explicit packaged MCP retrieval mode: `semantic` or `lexical`. Lexical reads extraction unit JSON without provider autodetection, credentials or vector artifacts. |
706
+ | `WOODS_DIR` | unset | MCP extraction-index path, after a positional argument and before `WOODS_OUTPUT`. See precedence below. |
707
+ | `WOODS_OUTPUT` | unset | MCP index-path fallback when neither a positional path nor `WOODS_DIR` is set; unreleased after `2.0.0.beta3`. |
708
+ | `WOODS_REQUIRE_INDEX` | unset | Set to `"1"` to fail closed: the server refuses to boot (raises `MissingArtifact`) unless a real index (`woods.json`) is present. By default an extract-only host boots in pattern/structural mode without it. Explicit lexical mode requires a valid published extraction index, not `woods.json`. |
552
709
  | `WOODS_ALLOW_AUTODETECT` | unset | **Deprecated no-op.** Auto-detect is now the default; accepted for backward compatibility only. |
553
710
  | `WOODS_SEARCH_MAX_SCAN` | `500` | Cap on unit files loaded during a phase-2 (metadata/source_code) `search`. Hitting the cap sets `partial: true` in the response. |
554
711
  | `WOODS_SNAPSHOTS` | unset | Set to `"true"` to force-enable temporal snapshot storage, even without a pre-existing SQLite database. |
555
712
  | `WOODS_ALLOW_PURGE` | unset | Set to `"1"` to override the 30%-deletion purge guard in `woods:embed`/`woods:embed_incremental`. |
556
- | `WOODS_PAYLOAD_RETENTION` | `3` | How many past generations' payload directories (`payloads/gen-N/`) to retain, and — when the JSON snapshot store is in use — how many temporal snapshots (`snapshots/`) to keep. A payload pinned by an active reader process is kept temporarily beyond this bound and reconsidered after the pin is released. |
713
+ | `WOODS_PAYLOAD_RETENTION` | `3` | How many past generations' payload directories (`payloads/gen-N/`) to retain, and — when the JSON snapshot store is in use — how many temporal snapshots (`snapshots/`) to keep. JSON snapshot retention counts corrupt or unreadable SHA-named files and prunes them first; the just-captured snapshot is protected. Cleanup failures are non-fatal. A payload pinned by an active reader process is kept temporarily beyond this bound and reconsidered after the pin is released. |
557
714
  | `WOODS_MCP_CACHE_TTL_MS` | `10000` | Cache TTL advertised in tool result `_meta`. `0` disables caching. |
558
715
  | `WOODS_NO_UPDATE_CHECK` | unset | Set to `"1"` to skip the `woods_status` RubyGems version check. |
559
716
  | `XDG_CACHE_HOME` | `~/.cache` | Base directory for the best-effort update-check cache (`$XDG_CACHE_HOME/woods/update_check.json`). An unset or empty value uses `~/.cache`; if the home directory cannot be resolved, Woods falls back to the system temporary directory. |
@@ -563,6 +720,20 @@ These variables are read by the gem and its MCP servers at runtime. They complem
563
720
  | `WOODS_QDRANT_URL`, `WOODS_QDRANT_COLLECTION`, `WOODS_QDRANT_API_KEY` | n/a | Override/require Qdrant connection settings when a pgvector/Qdrant-backed index is served outside its host application (no `Woods.configuration` available). |
564
721
  | `WOODS_PG_URL` | n/a | Required when a pgvector-backed index is served outside its host application. |
565
722
 
723
+ **MCP index path precedence (unreleased after `2.0.0.beta3`):** positional
724
+ argument → `WOODS_DIR` → `WOODS_OUTPUT` → current directory for `woods-mcp`
725
+ and `woods-mcp-http`. `woods-mcp-start` still requires one of the first three;
726
+ it never silently selects the current directory. An explicitly empty
727
+ `WOODS_DIR` remains an invalid override rather than falling through. Earlier
728
+ versions accept the positional path or `WOODS_DIR`; use an explicit path for
729
+ portable client configuration.
730
+
731
+ Paths are resolved in the MCP process's working directory and filesystem.
732
+ A published index can have `generation.json` pointing to a payload's
733
+ `manifest.json`; a root `manifest.json` is only the legacy flat layout. If
734
+ startup cannot find a manifest, check the reported directory and point at the
735
+ existing index before deciding another extraction is needed.
736
+
566
737
  ### Rake tasks
567
738
 
568
739
  | Variable | Default | Purpose |
@@ -590,24 +761,52 @@ These variables are read by the gem and its MCP servers at runtime. They complem
590
761
 
591
762
  | Variable | Default | Purpose |
592
763
  |----------|---------|---------|
593
- | `WOODS_IGNORE_WATCH` | unset | Set to `"1"` to make `woods:incremental`/`woods:clean` proceed even when a daemon is (or claims to be) running. For `woods:incremental` this removes daemon coverage: a git range that fails to resolve then exits 1 instead of standing down (see [Incremental Extraction](./INCREMENTAL_EXTRACTION.md#exit-behavior-in-ci-chains)). |
764
+ | `WOODS_IGNORE_WATCH` | unset | Set to `"1"` to make `woods:incremental`/`woods:clean`/`woods:hook_refresh` proceed even when a daemon is (or claims to be) running. For `woods:incremental` this removes daemon coverage: a git range that fails to resolve then exits 1 instead of standing down (see [Incremental Extraction](./INCREMENTAL_EXTRACTION.md#exit-behavior-in-ci-chains)). |
594
765
  | `WOODS_LOCK_WAIT` | `Watch::Daemon::LOCK_STALE_TIMEOUT` (600s) | How long a rake writer waits for `PipelineLock` before exiting non-zero. |
595
766
  | `WOODS_WATCH_POLL` | auto-detected | Set to `"1"`/`"0"` to force/disable polling mode (vs. `listen` gem, e.g. in a container without inotify). |
767
+ | `WOODS_WATCH_POLL_INTERVAL` | `1.0` (seconds) | Positive, finite delay between polling scans; also used on native-watcher fallback. Does not force polling. Larger values reduce scan frequency and can delay detection and shutdown. |
596
768
  | `WOODS_WATCH_DEBOUNCE` | `0.4` (seconds) | Delay before processing a batch of file-change events. |
597
769
  | `WOODS_WATCH_FULL_THRESHOLD` | `50` | Number of changed paths in one batch that triggers a full extraction instead of incremental. |
598
770
  | `WOODS_WATCH_IDLE_TIMEOUT` | unset (no timeout) | Seconds of inactivity before the daemon exits. |
599
771
  | `WOODS_WATCH_CATCH_UP` | `1` (enabled) | Set to `"0"` to skip generation-watermark catch-up on daemon start. |
772
+ | `WOODS_WATCH_TRUST_FOREIGN_HOST` | unset (disabled) | Set to `"1"` in each task/MCP reader to trust a foreign daemon's heartbeat for up to 15 minutes, without a local pid check. See [cross-host liveness](WATCH_DAEMON.md#cross-host-liveness) for clock bounds, degraded coverage, and startup limitations. |
773
+
774
+ ### Opt-in plugin refresh hooks
775
+
776
+ These settings control the plugin shell worker. Check installed
777
+ `woods:hook_refresh` support first; the task is unreleased after 2.0.0.beta2.
778
+ See [hook coverage and retry](WATCH_DAEMON.md#hooks-for-agent-sessions) and
779
+ [optional context limits](WATCH_DAEMON.md#optional-bounded-context-hints).
780
+
781
+ | Variable | Default | Purpose |
782
+ |----------|---------|---------|
783
+ | `WOODS_HOOKS_ENABLED` | unset (disabled) | Exact `1` enables the refresh/session hooks when an index exists. |
784
+ | `WOODS_HOOKS_DISABLED` | unset | Exact `1` disables both refresh and optional context hooks. |
785
+ | `WOODS_HOOK_CONTEXT_ENABLED` | unset (disabled) | Exact `1` enables separate bounded Claude orientation/impact hints; independent of refresh enablement. |
786
+ | `WOODS_HOOK_CONTEXT_COMMAND` | `bundle exec woods-hook-context` | Installed helper argv prefix; startup counts toward the fixed context deadline. Use a wrapper for quoting/container environment. |
787
+ | `WOODS_HOOK_CONTEXT_ROOT` | payload cwd | Explicit runtime-visible application root for context path mapping; set inside a container when host paths differ. |
788
+ | `WOODS_HOOK_RAKE` | `bundle exec rake` | Application command prefix; supports `docker compose exec -T app bundle exec rake`. Use a wrapper for shell quoting or explicit container environment. |
789
+ | `WOODS_SOURCE_CAPTURE` | internal | Private, one-use `woods-extract` child handoff. Do not set or persist this variable manually; see [source freshness](SOURCE_FRESHNESS.md). |
790
+ | `WOODS_HOOK_TIMEOUT_SECONDS` | `600` | PostToolUse worker deadline (SessionStart uses a fixed ten seconds), integer 1–3600 seconds; failed/deferred batches remain queued. Docker-side cancellation requires separate verification. |
791
+ | `WOODS_HOOK_LOCK_STALE_SECONDS` | `1800` | Age used only to reclaim legacy empty mkdir locks; live PID owners are never reclaimed merely by age. |
792
+
793
+ `woods:hook_refresh[<base64 JSON>]` is the internal plugin transport. Version 1
794
+ contains `output` and an `events` array of `{path, operation}` records; paths are
795
+ application-relative and operations are `add`, `update`, `delete`, or `move`.
796
+ The task validates inputs, defers active daemons with exit 75 before Rails boot,
797
+ and checks publication failure before acknowledging work. Use ordinary extraction
798
+ tasks for manual refreshes; hook transport is not a general shell execution API.
600
799
 
601
800
  ### Extraction rake tasks
602
801
 
603
802
  | Variable | Default | Purpose |
604
803
  |----------|---------|---------|
605
- | `WOODS_OUTPUT` | `Woods.configuration.output_dir` | Overrides the output directory for `woods:extract`/`woods:incremental`/`woods:watch` without editing the initializer. |
804
+ | `WOODS_OUTPUT` | `Woods.configuration.output_dir` | Overrides the output directory for extraction/watch and embedding tasks without editing the initializer; embedding also places its default SQLite metadata database there. Explicit database options take precedence. |
606
805
  | `CHANGED_FILES` | unset | Comma-separated explicit changed-path list for `woods:incremental`; when set, git range resolution is skipped entirely. |
607
806
  | `CI_COMMIT_BEFORE_SHA`, `CI_COMMIT_SHA` | unset (GitLab) | Build the diff range `<before>..<after>` for `woods:incremental`. A zero before-SHA (new branch) makes the range unresolvable, which exits 1 unless a running daemon covers the index. |
608
807
  | `GITHUB_BASE_REF` | unset (GitHub Actions) | Build the diff range `origin/<ref>...HEAD` for `woods:incremental`; an unfetched ref makes the range unresolvable, same exit behavior. |
609
808
  | `RAILS_ENV` | `development` | Rails environment the rake tasks boot in. |
610
- | `WOODS_PROFILE` | unset | Set to `"1"` to log one `[Woods] [profile] <phase> in N.NNs` line per run phase (payload seed, previous graph load, eager load, extraction or blast radius and re-extraction, type index, graph analysis, flows, manifest and summary, publish). Complements the per-extractor timing lines, which cover extraction only. Off by default and free when off. |
809
+ | `WOODS_PROFILE` | unset | Set to `"1"` to log disjoint `[Woods] [profile] <phase> in N.NNs` durations, including git enrichment, reconciliation, payload sync, pointer publication (`publish`) and retention (`payload prune`). Separate `[profile total]` lines report whole extraction wall time, including unprofiled setup and failed runs; never add these totals to phase durations. Excludes process/Rails boot before extraction. Off by default. |
611
810
  | `WOODS_GIT_DIR` | unset | Absolute path to the canonical git directory. Wins over the repository Woods would otherwise find, at all three of its git call sites: per-unit `commit_count`/`change_frequency` (enrichment), `manifest.json`'s `git_branch`/`git_sha` (provenance), and the `woods:incremental` diff range. All three build their command line with `Woods::GitCommand.argv`. |
612
811
  | `GIT_BRANCH`, `GIT_SHA` | unset | Provenance for a checkout with no `.git` at all (a source tarball, a Docker `COPY` that excludes it). Ignored when a `.git` is present but unresolvable, so a stale build arg cannot mask a worktree. |
613
812
 
@@ -645,6 +844,49 @@ WOODS_GIT_DIR=/canonical-git bundle exec rake woods:extract
645
844
 
646
845
  The `woods-mcp` bootstrapper emits a one-line STDERR banner at startup indicating whether semantic search is enabled and which provider is active. If no key/instance is found, pattern search still works and `codebase_retrieve` surfaces an actionable fix message.
647
846
 
847
+ ## Git enrichment history
848
+
849
+ Current source requires **Git 2.31 or newer** for optional per-unit git
850
+ metadata. Extraction still succeeds when git is unavailable or history cannot
851
+ be read completely. Git enrichment is omitted in either case; a failed or
852
+ incomplete streamed history read logs a warning.
853
+ This requirement and the history policy below are unreleased after 2.0.0.beta2.
854
+
855
+ Per-unit enrichment also requires a non-shallow repository. A shallow checkout
856
+ or a failed repository-depth probe omits enrichment with one warning per
857
+ extractor instance. Fetch complete history (`git fetch --unshallow`, or
858
+ `actions/checkout` with `fetch-depth: 0`) and run full extraction to refresh
859
+ retained metadata. If depth cannot be verified, check git access and version.
860
+ A source archive without a repository remains quiet.
861
+
862
+ Full and incremental extraction use one streamed `HEAD` history walk, restricted
863
+ to the last 365 days by git's `--since` traversal. Only requested app-owned paths
864
+ are retained. Commit counts and contributors describe **HEAD-reachable touched-path
865
+ events**, independent of which other paths are requested:
866
+
867
+ - A root commit compares with an empty tree; a normal commit compares with its parent.
868
+ - A merge compares with its first parent, while traversal still visits all parents.
869
+ A normal merge can therefore count both a side commit and the merge that introduces
870
+ its change. An `ours` merge touches no paths itself, but its side commits remain
871
+ reachable and count. A conflict-resolution merge counts when its result differs
872
+ from the first parent.
873
+ - Renames are deletion/addition events at exact current names; Woods never follows
874
+ previous names. Unmerged branches, remote-only refs, and checkpoint refs are excluded.
875
+ - `change_frequency` uses total events and events newer than 90 days. Contributors
876
+ and recent commits retain their existing top-five limit; recent commits and
877
+ `last_modified` follow git's traversal order, not a separate global timestamp sort.
878
+
879
+ **Compatibility:** merge-related counts, authors, recent commits, and derived
880
+ churn analysis can differ from the old 500-path batches, which used git's
881
+ pathspec history simplification. This is an intentional semantics change.
882
+ Run a full extraction after upgrading to replace retained incremental metadata
883
+ consistently. No configuration key enables the new policy; rollback uses the
884
+ previous gem plus a full extraction. Shallow checkouts still provide truncated
885
+ history; fetch the complete history for complete 365-day evidence.
886
+
887
+ The single walk removes repeated pathspec matching. Its end-to-end speedup on
888
+ the original #305 host has not yet been measured.
889
+
648
890
  ## Database compatibility
649
891
 
650
892
  All storage options work with both MySQL and PostgreSQL, except:
@@ -653,3 +895,12 @@ All storage options work with both MySQL and PostgreSQL, except:
653
895
  - **SQLite metadata store**: uses a standalone SQLite database file, independent of your app's database
654
896
 
655
897
  See [BACKEND_MATRIX.md](BACKEND_MATRIX.md) for the full compatibility matrix.
898
+
899
+ ### Explicit retrieval and discovery scope
900
+
901
+ On a server whose tool schema advertises them, `packages` and `source_paths` narrow
902
+ `search` and `codebase_retrieve` before candidate limits. These are per-call
903
+ arguments, not configuration settings. Inspect applied scope and completeness;
904
+ a narrow graph query can omit relevant cross-boundary dependencies. See the
905
+ [scope contract](RETRIEVAL_GUIDE.md#explicit-package-and-source-path-scopes) for
906
+ root/nested ownership, path normalization, errors, storage support, and cost.
@@ -26,13 +26,23 @@ The simplest setup. The `woods:console` rake task boots Rails, then starts the e
26
26
  ```ruby
27
27
  Woods.configure do |config|
28
28
  config.console_mcp_enabled = true
29
- config.console_mcp_token = ENV["WOODS_CONSOLE_MCP_TOKEN"]
29
+ config.console_mcp_http_enabled = false # stdio-only; no HTTP endpoint
30
30
  end
31
31
  ```
32
32
 
33
- The stdio and Docker entry points exit with status 1 while this setting is false. Enabling it grants the MCP process live read access under the blocked-table, redaction, and credential-scanning controls described below.
33
+ The stdio and Docker entry points exit with status 1 while `console_mcp_enabled` is false. Enabling it grants the MCP process live read access under the blocked-table, redaction, and credential-scanning controls described below.
34
34
 
35
- The token authenticates HTTP requests; a stdio client does not send it. Production Rails boot nevertheless requires a token of at least 32 characters whenever Console MCP is enabled, even for a stdio-only setup. Store `WOODS_CONSOLE_MCP_TOKEN` in the application's normal secret store. Outside production a missing token warns and leaves Console HTTP guarded with 401 — the boot warning names both transports, so a stdio-only setup can tell that the 401 is not its symptom and that its own session still works.
35
+ `console_mcp_http_enabled` defaults to `true` to preserve existing HTTP
36
+ setups. Set it to `false` for stdio-only use: HTTP guards and the Console
37
+ middleware pass requests through to Rails, and boot skips HTTP token checks,
38
+ including in production. Stdio does not send or consume a bearer token.
39
+
40
+ If both Console flags are enabled, HTTP still requires a token of at least
41
+ 32 characters. Missing tokens warn outside production and every guarded
42
+ request returns 401; production refuses to boot. A configured short token
43
+ raises at boot in every environment while HTTP is enabled. Store the token
44
+ in the application's normal secret store. Before enabling HTTP later, set
45
+ its token, allowed origins and TLS as described in [Option C](#option-c-http-rack-middleware).
36
46
 
37
47
  ### How it works
38
48
 
@@ -172,6 +182,7 @@ In an initializer (`config/initializers/woods.rb`):
172
182
  ```ruby
173
183
  Woods.configure do |config|
174
184
  config.console_mcp_enabled = true
185
+ config.console_mcp_http_enabled = true
175
186
  config.console_mcp_token = ENV.fetch('WOODS_CONSOLE_MCP_TOKEN')
176
187
  config.console_mcp_allowed_origins = [
177
188
  'https://rails.internal.example', # public Rails/MCP Host
@@ -187,6 +198,12 @@ the Rails server environment. The middleware stack registers automatically via
187
198
  the gem's Railtie and requires `Authorization: Bearer <token>` on every Console
188
199
  request. Missing or incorrect tokens receive `401 Unauthorized`.
189
200
 
201
+ The HTTP authentication scheme is ASCII case-insensitive (`Bearer`, `bearer`,
202
+ or `BEARER`); the token remains case-sensitive and must match exactly after one
203
+ space. This applies to both Console HTTP and `woods-mcp-http`. Case-insensitive
204
+ scheme support is unreleased after `2.0.0.beta3`; use the canonical `Bearer`
205
+ spelling in client configuration for compatibility with earlier releases.
206
+
190
207
  For non-loopback access, `console_mcp_allowed_origins` must include the public
191
208
  Rails/MCP host. If a browser-based client sends an `Origin` header from a
192
209
  different host, include that exact origin too. This allow-list controls both
@@ -483,6 +500,14 @@ Until this flag is `true`, none of the transports route traffic:
483
500
 
484
501
  Keep the flag off in environments where the Console isn't needed (production web tier, CI). Flip it on per-environment, e.g. in `config/environments/development.rb` or a staging-only initializer, once the layers below are configured for that environment's threat model.
485
502
 
503
+ ### `console_mcp_http_enabled` (HTTP transport gate)
504
+
505
+ Defaults to `true` for compatibility. Set to `false` alongside
506
+ `console_mcp_enabled = true` to retain stdio access without enabling HTTP or
507
+ requiring an HTTP token. Both flags are read at request time, so settings in
508
+ `config/initializers/woods.rb` take effect. Disabling HTTP does not disable
509
+ blocked-table, redaction, credential-scanning or rollback controls on stdio.
510
+
486
511
  ### `console_blocked_tables` (layer 1: table gate)
487
512
 
488
513
  Entries are lowercased table names. A tool call is rejected at dispatch time when:
@@ -536,7 +561,11 @@ registered in a supported mode, so this setting does not enable eval.
536
561
  Woods::Console::Server.rebuild_credential_index(rails_app: Rails.application)
537
562
  ```
538
563
 
539
- This rebuilds the index from the current credentials and hot-swaps it into the active scanner. The swap is atomic on MRI, in-flight scans see either the old or the new index, never a partial one. The method is a no-op (returns `nil`) when no server has been built yet or when `console_credential_defense_enabled` is `false`.
564
+ This reads a fresh encrypted-file and key snapshot, without changing Rails' cached application credentials, and replaces the index in every live embedded Console server in this process. Each response scan keeps one complete index; a rotation cannot change its index halfway through a response. Servers released by their transports are not retained by this registry. A later server construction also reads fresh credentials.
565
+
566
+ A refresh failure (including missing files or keys, failed decryption, and invalid YAML) raises and leaves every existing index intact. Treat a failed rebuild as an operational failure: resolve the credentials deployment and retry, or restart after verification. A valid empty credential mapping intentionally clears the index. A successful rebuild replaces the old set rather than retaining removed secret values. Custom `rails_app:` collaborators that expose `credentials.config` remain supported; those collaborators own freshness of their returned config.
567
+
568
+ The method is a no-op (returns `nil`) when no live scanner remains or when `console_credential_defense_enabled` is `false`. Normal server boot still permits unavailable credentials and falls back to the other configured defenses; this permissive boot behavior does not apply to an explicit rebuild.
540
569
 
541
570
  **Rotation warning.** At boot time, Woods checks whether any credentials file (`config/credentials.yml.enc`, `config/credentials/<env>.yml.enc`) was modified *after* the process started. When it detects this, it emits a `console.credential_index.stale` warn-level structured log line with the file path, mtime, and a hint to restart or call `rebuild_credential_index`. This check is on by default; disable it with:
542
571
 
@@ -658,7 +687,8 @@ touching ActiveRecord, and neither is registered in `tools/list`.
658
687
  ```ruby
659
688
  # config/initializers/woods.rb
660
689
  Woods.configure do |config|
661
- config.console_mcp_enabled = true # mount the Rack middleware via Railtie
690
+ config.console_mcp_enabled = true # master switch
691
+ config.console_mcp_http_enabled = true # false for stdio-only use
662
692
  config.console_mcp_token = ENV.fetch('WOODS_CONSOLE_MCP_TOKEN')
663
693
  config.console_embedded_read_tools = true # unlock console_sql / console_query
664
694
  config.console_redacted_columns = Woods::DEFAULT_CONSOLE_REDACTED_COLUMNS
@@ -736,12 +766,12 @@ Each transaction sets a statement timeout before any query runs. The default is
736
766
 
737
767
  `SqlValidator` rejects non-read-only SQL at the string level, before any database interaction.
738
768
 
739
- Validation runs **once**, inside the executor, with the dialect of the live adapter. There is deliberately no earlier dialect-blind pre-check in the tool handler: a validator built without a dialect is the conservative MySQL+PostgreSQL union, and running it first meant a MySQL host rejected statements whose `\'`/backtick grammar produces a spuriously forbidden PostgreSQL view — the adapter-aware acceptance below could never be reached on a real transport. The executor raises `SqlValidationError` for anything it refuses, which the dispatch pipeline renders as a tool error, so nothing is ungated.
769
+ Validation runs **once**, inside the executor, with the dialect of the live adapter. There is deliberately no earlier dialect-blind pre-check in the tool handler: a validator built without a dialect is the conservative union of supported dialects, and running it first meant a MySQL host rejected statements whose `\'`/backtick grammar produces a spuriously forbidden PostgreSQL view — the adapter-aware acceptance below could never be reached on a real transport. The executor raises `SqlValidationError` for anything it refuses, which the dispatch pipeline renders as a tool error, so nothing is ungated.
740
770
 
741
771
 
742
772
  - **Allowed prefixes:** `SELECT`, `WITH...SELECT`, and plain `EXPLAIN`. `EXPLAIN ANALYZE` is rejected, it executes the query rather than just planning it (both the whitespace and `EXPLAIN (ANALYZE, …)` option-list spellings).
743
773
  - **Rejected prefixes:** `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `DROP`, `ALTER`, `TRUNCATE`, `CREATE`, `GRANT`, `REVOKE`
744
- - **Rejected anywhere in query:** `UNION`, `INTO`, `COPY`; row-lock clauses (`FOR UPDATE`, `FOR NO KEY UPDATE`, `FOR SHARE`, `FOR KEY SHARE`, `FOR UPDATE NOWAIT`/`SKIP LOCKED`, MySQL `LOCK IN SHARE MODE`) — these take live row locks even inside the rolled-back transaction. The lock check is adapter-aware: `console_sql` validates with the active adapter's dialect, including MySQL double-quoted strings/backtick identifiers and PostgreSQL quoted identifiers/E-strings. Unknown adapters conservatively scan both normalizations. Every view is scanned under both MySQL executable-comment (`/*!...*/`) semantics, so `#` comments and version-guarded comments cannot split a clause apart.
774
+ - **Rejected anywhere in query:** `UNION`, `INTO`, `COPY`; row-lock clauses (`FOR UPDATE`, `FOR NO KEY UPDATE`, `FOR SHARE`, `FOR KEY SHARE`, `FOR UPDATE NOWAIT`/`SKIP LOCKED`, MySQL `LOCK IN SHARE MODE`) — these take live row locks even inside the rolled-back transaction. The lock check is adapter-aware: `console_sql` validates with the active adapter's dialect, including MySQL double-quoted strings/backtick identifiers and PostgreSQL quoted identifiers/E-strings. Unknown adapters conservatively scan all supported normalizations. Every view is scanned under both MySQL executable-comment (`/*!...*/`) semantics, so `#` comments and version-guarded comments cannot split a clause apart.
745
775
  - **Function allowlist (the authoritative function control):** every function-call-shaped identifier must appear in `ALLOWED_FUNCTIONS`, a conservative set of pure read-only functions (aggregates, window functions, string/number/date/JSON readers) kept portable across MySQL, PostgreSQL, and SQLite. Anything else is rejected by name, quoted forms (`"pg_terminate_backend"(…)`) included. This is an allowlist because a denylist cannot enumerate every side-effecting function (`nextval`, `pg_advisory_lock`, `pg_terminate_backend`, …). A legacy `DANGEROUS_FUNCTIONS` denylist (`pg_sleep`, `lo_import`, `lo_export`, `pg_read_file`, `pg_write_file`, `load_file`, `sleep`, `benchmark`) still runs first as belt-and-suspenders.
746
776
  - **Rejected patterns:** multiple statements (semicolons), writable CTEs (every `AS (...)` body is checked, so a writable CTE in any WITH position is refused — `WITH a AS (SELECT 1), b AS (DELETE FROM users RETURNING *) SELECT * FROM b`), a CTE list attached to top-level DML (`WITH a AS (SELECT 1) DELETE FROM users RETURNING *`), comment-hidden injections
747
777
 
@@ -753,6 +783,12 @@ For `console_query`, a schema-qualified column reference such as `orders.total`
753
783
 
754
784
  Scope hashes accept Ransack-style predicate suffixes (`_eq`, `_not_eq`, `_gt`, `_gteq`, `_lt`, `_lteq`, `_in`, `_not_in`, `_null`, `_not_null`, `_present`, `_blank`, `_matches`), see the [cookbook](MCP_TOOL_COOKBOOK.md#scope-predicates) for the full table. Every column name in a suffixed key is validated before an Arel predicate is built, so SQL injection via column names is not possible.
755
785
 
786
+ The internal scope-array defense also uses the connected adapter's dialect and
787
+ MySQL session quote modes when rejecting subqueries and forbidden keywords.
788
+ This protects direct/legacy executor callers; supported tool schemas continue
789
+ to enforce their narrower parameterized scope grammar.
790
+
791
+
756
792
  ---
757
793
 
758
794
  ## Troubleshooting
@@ -823,7 +859,44 @@ quote and comment rules. MySQL also reads the executing session's `ANSI_QUOTES`
823
859
  and `NO_BACKSLASH_ESCAPES` settings for validation, protected-column scanning, and
824
860
  table gating; adjacent subtraction operators are not assumed to begin a comment.
825
861
  Direct scanner callers without session settings use conservative quote-mode scans.
862
+ SQLite read SQL accepts simple ASCII bare, double-quoted, or backtick identifiers
863
+ (letters, digits, and underscores, starting with a letter or underscore).
864
+ Use whitespace after `FROM` and `JOIN`, and use `SELECT` subqueries rather than
865
+ parenthesized table groups. Bracket-quoted and string-quoted names, quoted names
866
+ containing punctuation, and unsupported table-reference syntax are refused before
867
+ execution, because they cannot be reliably checked against the configured table
868
+ policy. Ordinary string literals remain supported. This restriction is part of
869
+ 2.0.0.beta4; keep read tools disabled on older versions when this policy is
870
+ needed. Confirm that the release is available before selecting it. The Rails-version
871
+ integration lane checks these boundaries on real SQLite.
872
+
826
873
  The contributor live-backend lane exercises these boundaries
827
874
  through Console requests against PostgreSQL and MySQL. Keep read tools disabled
828
875
  unless live SQL access is needed, and retain the configured blocked-table and
829
876
  redaction policies when diagnosing a rejected request.
877
+
878
+ ## Console policy corrections in 2.0.0.beta4
879
+
880
+ In `2.0.0.beta4`, the default model-reading tools check the resolved relation
881
+ against `console_blocked_tables` before fetching records or counts. This includes
882
+ application-defined default scopes and the parent lookup for association counts.
883
+ The checked relation is reused for execution so a dynamic default scope is not
884
+ resolved twice. These checks do not change which Console tools are enabled.
885
+
886
+ Response handling redacts protected fields before invoking serializers, converts
887
+ the remaining response to JSON-compatible values, then redacts and scans that
888
+ normalized tree before either JSON or Markdown rendering. Symbol values and custom
889
+ JSON serializers therefore receive the same credential checks as ordinary strings.
890
+ Custom values in Markdown now use their JSON-compatible representation. Numbers,
891
+ booleans, nulls, and ordinary record shapes retain their existing meanings.
892
+
893
+ For SQLite SQL, keyword spellings receive function-policy exceptions only where
894
+ supported query grammar requires them. PostgreSQL reserved-keyword grammar is
895
+ preserved. Unsupported parenthesized offset expressions on other dialects may
896
+ require a plain numeric offset. Keep the configured access and credential policies
897
+ in place when adjusting a query.
898
+
899
+ These corrections require `2.0.0.beta4` or a reviewed development revision that
900
+ contains them. Confirm that a patched release is available before selecting it.
901
+ On affected versions, disable Console where these policies are required; Index MCP
902
+ can stay enabled because it reads the published code index separately.