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
@@ -0,0 +1,213 @@
1
+ # Reading a published index from Ruby
2
+
3
+ `Woods::PublishedIndex` is the stable, read-only API for tools that are not MCP clients: RuboCop cops, CI gate scripts, and the `woods:check:*` tasks. It needs no Rails, opens one published generation, and never moves off it for the life of the reader.
4
+
5
+ ```ruby
6
+ require 'woods/published_index'
7
+
8
+ index = Woods::PublishedIndex.new(Rails.root.join('tmp/woods'))
9
+ index.generation_number # => 42
10
+ index.unit('Order') # => { "type" => "model", "identifier" => "Order", ... }
11
+ index.units(type: 'model') # => index entries with a "type" key
12
+ index.edges(via: 'belongs_to') # => [{ from: "Comment", to: "Post", via: "belongs_to", through: nil, through_db: nil, disable_joins: false }]
13
+ index.dependents_of('Post') # => ["Comment", "PostsController"]
14
+ index.table_database_map # => { "orders" => "primary", "events" => "analytics" }
15
+ index.external_dependency_checksum # => "9f2c81ad..." (changes on every publish)
16
+ index.close
17
+ ```
18
+
19
+ Prefer the block form so the lock in the next section is always released:
20
+
21
+ ```ruby
22
+ Woods::PublishedIndex.open(Rails.root.join('tmp/woods')) do |index|
23
+ index.table_database_map
24
+ end
25
+ ```
26
+
27
+ ## One generation, pinned for the reader's whole life
28
+
29
+ Unlike `Woods::MCP::IndexReader`, a `PublishedIndex` never refreshes between calls. It resolves one generation at `.new`/`.open` time and every fact it returns, `units`, the table map, `generation_number`, `external_dependency_checksum`, comes from that one generation for as long as the reader is open. There is no `reload` and no auto-refresh: open a new reader to see a later publish.
30
+
31
+ Opening a numbered generation acquires that generation's `manifest.json` lock through the same retention protocol `Woods::PayloadStore#prune` respects (a shared advisory `flock`, taken read-only). The lock is held for the reader's whole lifetime, not just one read, so a publish's retention pass cannot remove the payload out from under a long-lived reader: `PayloadStore#prune` takes a non-blocking exclusive lock on the same file before removing a generation, and skips any generation it cannot lock. `#close` releases it (`ensure` in the block form), so a reader that is done reading stops holding the generation open.
32
+
33
+ **Not thread-safe.** A `PublishedIndex` instance is meant for one script or cop process reading one generation; it keeps no mutex around its lock file or its underlying `Woods::MCP::IndexReader`. Give each thread its own reader rather than sharing one.
34
+
35
+ ## API
36
+
37
+ | Method | Returns | Notes |
38
+ |---|---|---|
39
+ | `.new(index_dir, generation: nil)` | reader, holding a lock | `generation:` pins a published `payloads/gen-N`; omit for the currently published one |
40
+ | `.open(index_dir, generation: nil) { |index| ... }` | the block's value | releases the lock in `ensure`, on return or exception |
41
+ | `.available_generations(index_dir)` | `Array<Integer>` | Published generations, ascending: see below |
42
+ | `#generation_number` | Integer | 0 for an index written flat (pre-2.0 layout) |
43
+ | `#close` | nil | Releases the retention lock; safe to call more than once |
44
+ | `#unit(identifier, type: nil)` | Hash or nil | The unit JSON, string keys; see the collision note below |
45
+ | `#units(type: nil)` | `Array<Hash>` | `_index.json` entries plus `"type"` |
46
+ | `#edges(via: nil)`, `#each_edge` | `Array<Hash>` | Every forward edge with `through`, `through_db`, and `disable_joins`; an identifier shared by more than one type contributes one edge per owning type, never folded into a single deduplicated entry |
47
+ | `#dependents_of(identifier, via: nil)` | `Array<String>` | Reverse index |
48
+ | `#table_database_map` | `Hash` | Model table to database; empty on Rails 6.0 extractions |
49
+ | `#external_dependency_checksum` | String | SHA-256 of the pinned payload's `manifest.json` |
50
+
51
+ The reader wraps `Woods::MCP::IndexReader` with `auto_refresh: false`; the unit and graph shapes are the ones documented in [Extractor reference](EXTRACTOR_REFERENCE.md#extractedunit-field-reference).
52
+
53
+ ### `#unit`: an identifier shared across types
54
+
55
+ `Woods::MCP::IndexReader#find_unit` keys its identifier map on identifier alone. If two type directories both list the same identifier (a model and a service both named `Foo`, for example), whichever type sorts last in `Woods::MCP::IndexReader::TYPE_DIRS` silently wins, and `unit(identifier)` returns that one. Pass `type:` to read a specific type's unit file directly and skip the collision entirely; `#table_database_map` always does this internally (`type: 'model'`), so a same-named non-model unit can never shadow a model's `table_name`/`database`.
56
+
57
+ ### `available_generations`: published means published
58
+
59
+ A generation is listed only when both hold:
60
+
61
+ * its number is at or below the pointer `generation.json` currently names;
62
+ * its `payloads/gen-N` directory holds a `manifest.json`.
63
+
64
+ A directory numbered above the pointer (a payload built but never bumped to) and a directory missing its manifest (an interrupted or corrupted publish) are never listed, and `.new(index_dir, generation: N)` raises `ArgumentError` for either. Retention itself is bounded by `WOODS_PAYLOAD_RETENTION` (default 3).
65
+
66
+ A *missing* `generation.json` is not an error: it means a flat (pre-2.0) index, generation 0. A `generation.json` that **exists but will not parse** is different, a corrupt install, not an empty index, so `.available_generations` and `.new`/`.open` raise `Woods::PublishedIndex::CorruptPointerError` naming the file's path instead of silently reporting zero published generations.
67
+
68
+ ### Durability: the pointer is the commit point
69
+
70
+ The pointer is not only what makes a generation *visible*, it is what makes it *durable*.
71
+
72
+ Payload files are written through `Woods::AtomicFile.write` with `durable: false`: a temp file, a chmod to the final mode, and a rename, but no `fsync`. Immediately before `generation.json` is written, the writer flushes the whole payload directory once with `Woods::AtomicFile.sync_directory_tree`, and then writes the pointer durably.
73
+
74
+ So the contract a reader can rely on is:
75
+
76
+ > When `generation.json` is durable, every file in the payload it names is durable.
77
+
78
+ What that gives up is an individual payload file being durable *before* the pointer exists. Nothing reads a payload file in that window: every reader resolves through the pointer, and a crash there leaves an unreferenced partial payload that the next run prunes. The saving is large, since two forced flushes per file is about 8.9ms each on btrfs, or 71s for 8000 units against 1s for one flush.
79
+
80
+ `sync_directory_tree` tries `syncfs(2)` through Fiddle, then `sync -f <dir>`, then a bare `sync`, then an `fsync` on every file in the tree. The last resort is what keeps this honest: the chain never silently does nothing, it only gets slower.
81
+
82
+ A file whose readers do **not** resolve through the pointer keeps its own `fsync`: `generation.json` itself, the watch daemon's `watch_status.json`, the update check cache, the Obsidian and Unblocked exports, Notion sync state, temporal snapshots, `checkpoint.json`, and MCP task records. Set `durable_payload_writes = true` to pay the per-file `fsync` on payload files as well; it cannot disable the publish flush.
83
+
84
+ ## Keying a RuboCop cache on the index
85
+
86
+ RuboCop caches offenses per file and invalidates the cache when a cop's `external_dependency_checksum` changes. `rubocop-rails` uses this to re-run schema-aware cops when `db/schema.rb` changes. The same pattern works against Woods: the pinned payload's `manifest.json` is rewritten through `AtomicFile` on every publish, so its digest is the checksum.
87
+
88
+ ### Worked example: `Multidb/ForeignKeyAcrossDatabases`
89
+
90
+ The cop flags an `add_foreign_key` in a migration whose two tables resolve to different databases. The table-to-database map comes from the index (`metadata.database` on model units), so the cop never boots Rails. A `:through` association's edge carries the same join rule under `through_db`: the through model can resolve to a database that differs from either end, so a check built on `#edges` rather than `#table_database_map` needs that value to catch the same class of cross-database join.
91
+
92
+ ```ruby
93
+ # lib/rubocop/cop/multidb/foreign_key_across_databases.rb
94
+ # frozen_string_literal: true
95
+
96
+ require 'woods/published_index'
97
+
98
+ module RuboCop
99
+ module Cop
100
+ module Multidb
101
+ # Flags `add_foreign_key :from, :to` when the two tables live in
102
+ # different databases. MySQL and PostgreSQL both refuse the constraint
103
+ # at the database level; catching it in review is cheaper.
104
+ class ForeignKeyAcrossDatabases < Base
105
+ MSG = 'Foreign key from `%<from>s` (%<from_db>s) to `%<to>s` (%<to_db>s) crosses databases.'
106
+ RESTRICT_ON_SEND = %i[add_foreign_key].freeze
107
+
108
+ def_node_matcher :foreign_key_tables, <<~PATTERN
109
+ (send nil? :add_foreign_key ${sym str} ${sym str} ...)
110
+ PATTERN
111
+
112
+ def on_send(node)
113
+ foreign_key_tables(node) do |from_node, to_node|
114
+ from = from_node.value.to_s
115
+ to = to_node.value.to_s
116
+ from_db = table_databases[from]
117
+ to_db = table_databases[to]
118
+ next if from_db.nil? || to_db.nil? || from_db == to_db
119
+
120
+ add_offense(node, message: format(MSG, from: from, from_db: from_db, to: to, to_db: to_db))
121
+ end
122
+ end
123
+
124
+ # RuboCop re-runs the cop on every file when this changes, and the
125
+ # pinned manifest changes on every Woods publish.
126
+ def external_dependency_checksum
127
+ index.external_dependency_checksum
128
+ rescue ArgumentError
129
+ 'no-woods-index'
130
+ end
131
+
132
+ private
133
+
134
+ def table_databases
135
+ @table_databases ||= index.table_database_map
136
+ rescue ArgumentError
137
+ {}
138
+ end
139
+
140
+ def index
141
+ @index ||= Woods::PublishedIndex.new(File.join(Dir.pwd, 'tmp/woods'))
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ ```
148
+
149
+ Register it in `.rubocop.yml` with `require: ./lib/rubocop/cop/multidb/foreign_key_across_databases` and enable `Multidb/ForeignKeyAcrossDatabases` for `db/migrate/**/*.rb` and `db/*_migrate/**/*.rb`.
150
+
151
+ Keep the index current in CI so the cop sees the same generation the app runs on:
152
+
153
+ ```ruby
154
+ # config/ci.rb (Rails 8.1)
155
+ step "Woods: refresh", "bin/rails woods:incremental"
156
+ step "Style", "bin/rubocop"
157
+ ```
158
+
159
+ A memoized cop instance holds its `PublishedIndex` (and its retention lock) for the process's lifetime; RuboCop runs each cop once per process, so there is no explicit `close` call above.
160
+
161
+ ## Comparing two generations
162
+
163
+ `generation:` pins a retained payload. Two readers over two generations are the base for [`woods:check:moved_messages`](#moved-message-check), and for any check that asks "what changed between these two publishes".
164
+
165
+ ```ruby
166
+ before = Woods::PublishedIndex.new(dir, generation: 41)
167
+ after = Woods::PublishedIndex.new(dir, generation: 42)
168
+ added = after.units.map { |u| u['identifier'] } - before.units.map { |u| u['identifier'] }
169
+ before.close
170
+ after.close
171
+ added
172
+ ```
173
+
174
+ Retention is `WOODS_PAYLOAD_RETENTION` generations (default 3). Raise it on a CI runner that compares against an older baseline, or the older side of the comparison may no longer be published.
175
+
176
+ ## Moved-message check
177
+
178
+ `woods:check:moved_messages` opens two retained generations through `Woods::PublishedIndex` (block form, so both readers' retention locks always release) and reports every public method name that looks like it moved from one unit to another while a `:test_coverage` edge did not follow.
179
+
180
+ Each row is a **candidate move into a unit without mapped tests**, never a proven coverage loss: the check matches on method name and kind alone, so two unrelated methods that happen to share both look identical to a real move. Reported only when the source unit was covered before and the destination is not covered after; a method that was never covered is not a regression this check owns. `WOODS_CHECK_STRICT=1` exits 1 on any finding, but the check stays heuristic either way, strict mode changes the exit code, not the confidence of a row.
181
+
182
+ ```bash
183
+ bin/rails woods:check:moved_messages # previous retained generation vs the published one
184
+ bin/rails "woods:check:moved_messages[41,42]" # explicit generations
185
+ WOODS_CHECK_STRICT=1 bin/rails woods:check:moved_messages # exit 1 on findings, for CI (still heuristic)
186
+ WOODS_CHECK_JSON=1 bin/rails woods:check:moved_messages # also print findings as JSON
187
+ ```
188
+
189
+ With no `[from,to]` given, the generations default to the latest two from `available_generations` (a pure function, `Woods::Checks::GenerationResolution`, picks them); an index with fewer than two retained generations exits 1 with a message naming `WOODS_PAYLOAD_RETENTION` as the fix. A `generation.json` that will not parse raises `Woods::PublishedIndex::CorruptPointerError`, named by the task before it propagates, rather than being swallowed as "no findings".
190
+
191
+ ### Which key holds which method list
192
+
193
+ The check reads three metadata keys and normalizes them to one `[name, kind]` shape before matching, so an instance method never collides with a class method of the same bare name:
194
+
195
+ | Extractor family | Key | Shape |
196
+ |---|---|---|
197
+ | Services, POROs, managers, decorators, policies, validators, concerns, Pundit policies, lib units | `public_methods` | Regex-extracted; a class method is a bare name **prefixed with `self.`** (`"self.build"`), an instance method is bare (`"run"`) |
198
+ | Services, POROs, managers, decorators, policies, validators, concerns, lib units | `class_methods` | Regex-extracted `def self.foo` names, prefix already stripped |
199
+ | Concerns, models | `instance_methods` | Bare names; models get this from runtime introspection (`instance_methods(false)`, filtered), a wider list than the regex-based extractors produce |
200
+ | Phlex components, ViewComponents | `public_methods` | Runtime introspection (`public_instance_methods(false)`); always bare, since these extractors only ever report instance methods |
201
+
202
+ Model units carry both `instance_methods` and `class_methods` from runtime introspection, no `public_methods` key, and it is the widest of the method lists: every instance and class method the model responds to, not just the ones a source-regex can see.
203
+
204
+ | Situation | Reported |
205
+ |---|---|
206
+ | `total` left `Checkout` (covered) and appeared in `Pricing` (not covered) | yes |
207
+ | Same move, `Pricing` covered by a test_mapping unit | no |
208
+ | `total` was never covered before the move | no |
209
+ | Method renamed in place | no (nothing gained it) |
210
+ | An instance method and a same-named class method swap units | no (kind mismatch) |
211
+ | A `self.build` public method reappears as a `class_methods` entry named `build` | yes, `kind: :class` (normalized to the same signature) |
212
+
213
+ Temporal snapshots (`snapshot_diff`, `unit_history`) store content hashes only, so they cannot see a moved method; that is why the check reads payload generations. Keep at least two retained generations (`WOODS_PAYLOAD_RETENTION`, default 3).
data/docs/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # Woods documentation
2
+
3
+ Woods extracts runtime-accurate Rails context and serves it to coding agents through MCP. Start with the task you are trying to complete; you do not need to read the documentation in order.
4
+
5
+ ## What are you trying to do?
6
+
7
+ | Task | Start here | You will finish with |
8
+ |---|---|---|
9
+ | Install Woods in a Rails app | [Getting started](GETTING_STARTED.md) | A validated codebase index with the 14 packaged-default tools connected |
10
+ | Ask an agent to install or configure Woods | [Agent setup runbook](AGENT_SETUP.md) | A safe, reviewable install with an agent handoff report |
11
+ | Configure an MCP client or Docker path | [MCP servers](MCP_SERVERS.md) | A working Index Server and, if authorized, an optional Console Server |
12
+ | Use Woods tools as an agent | [Agent guide](AGENT_GUIDE.md) | A repeatable query workflow for code context, flows, and blast radius |
13
+ | Keep the index current automatically | [Watch daemon](WATCH_DAEMON.md) | A resident development process that catches up changes and republishes the index |
14
+ | Upgrade from Woods 1.x | [Upgrade to Woods 2.0](UPGRADING_TO_2.md) | A backed-up, re-indexed, verified v2 installation |
15
+ | Diagnose an error | [Troubleshooting](TROUBLESHOOTING.md) | Symptom-to-cause checks for extraction, MCP, embeddings, storage, and Docker |
16
+ | Contribute to Woods | [Contributing](../CONTRIBUTING.md) | A tested change with synchronized docs and plugin guidance |
17
+
18
+ Claude Code users can optionally install the packaged setup/configuration
19
+ workflows with `/plugin marketplace add lost-in-the/plugins`, followed by
20
+ `/plugin install woods-plugin@lost-in-the-plugins`. Woods and its MCP servers
21
+ do not otherwise depend on Claude Code; other clients use the same runbooks
22
+ and stdio or Streamable HTTP endpoints directly.
23
+
24
+ ## First-time setup
25
+
26
+ - [Getting started](GETTING_STARTED.md): install, review generated configuration, remove the unused legacy application migration unless an older/custom integration requires it, extract, validate, and connect the Index Server.
27
+ - [Agent setup runbook](AGENT_SETUP.md): the same result with version checks, repository safety, approval boundaries, and a copyable agent prompt.
28
+ - [Docker setup](DOCKER_SETUP.md): extraction inside the container, container-first MCP launch, optional host launch, and path translation.
29
+ - [Configuration reference](CONFIGURATION_REFERENCE.md): every supported option, default, and environment variable.
30
+ - [Backend matrix](BACKEND_MATRIX.md): choose structural-only, local Ollama, pgvector/OpenAI, Qdrant/OpenAI, or shared-filesystem deployment.
31
+
32
+ ## MCP and agents
33
+
34
+ - [MCP servers](MCP_SERVERS.md): choose the pre-extracted Index Server or live-data Console Server; configure clients; understand the callable 14 and 9/11 tool surfaces.
35
+ - [Agent guide](AGENT_GUIDE.md): start with `woods_status`, discover with `search`, inspect with `lookup`, and follow dependencies or flows.
36
+ - [MCP tool cookbook](MCP_TOOL_COOKBOOK.md): scenario-based calls with parameters and expected response shapes.
37
+ - [Console MCP setup](CONSOLE_MCP_SETUP.md): Console transports, blocked tables, credential scanning, redaction, SQL validation, and production safeguards.
38
+ - [MCP HTTP transport](MCP_HTTP_TRANSPORT.md): shared/remote Index Server transport, authentication, origins, and protocol details.
39
+ - [MCP worktree setup](MCP_WORKTREE_SETUP.md): register Woods correctly when agents work in linked git worktrees.
40
+
41
+ ## Index lifecycle
42
+
43
+ - [Retrieval guide](RETRIEVAL_GUIDE.md): configure embeddings and understand semantic retrieval, ranking, and token budgets.
44
+ - [Embedding models](EMBEDDING_MODELS.md): choose and size local Ollama models.
45
+ - [Upgrade to Woods 2.0](UPGRADING_TO_2.md): identifier changes, atomic payloads, durable-store reconciliation, and rollback.
46
+
47
+ ## Reference
48
+
49
+ - [Why Woods](WHY_WOODS.md): the problems runtime introspection solves.
50
+ - [Internals](INTERNALS.md): extraction, publication, graph, storage, retrieval, and MCP components.
51
+ - [Extractor reference](EXTRACTOR_REFERENCE.md): what each extractor produces and the edge cases it handles.
52
+ - [Reading a published index from Ruby](PUBLISHED_INDEX.md): the `Woods::PublishedIndex` Ruby API for cops, gate scripts, and `woods:check:*` tasks (including the moved-message check).
53
+ - [Evaluation](EVALUATION.md): retrieval scoring, baselines, and the agent-level index on/off ablation.
54
+ - [Backend matrix](BACKEND_MATRIX.md): implemented provider/store combinations and their operational requirements.
55
+ - [Token benchmark](TOKEN_BENCHMARK.md): evidence behind Woods token-estimation defaults.
56
+ - [FAQ](FAQ.md): short answers and links to the canonical guides.
57
+
58
+ ## Exports and integrations
59
+
60
+ - [Notion integration](NOTION_INTEGRATION.md)
61
+ - [Obsidian integration](OBSIDIAN_INTEGRATION.md)
62
+ - [Unblocked integration](UNBLOCKED_INTEGRATION.md)
63
+
64
+ ## Maintainer material
65
+
66
+ Historical build-phase documents are not user guides. Source checkouts also contain the [MCP protocol decision record](https://github.com/lost-in-the/woods/blob/main/docs/design/MCP_2026_STRATEGY.md), [generated self-analysis diagrams](https://github.com/lost-in-the/woods/tree/main/docs/self-analysis), and the maintainer work ledger in `backlog.json`; these maintainer-only paths are not packaged with the gem.
67
+
68
+ Contract records that read as maintainer reference rather than guides:
69
+
70
+ - [Incremental extraction](INCREMENTAL_EXTRACTION.md): the incremental/full equivalence contract and dispatch inventory.
71
+ - [Watch daemon](WATCH_DAEMON.md): the resident daemon's design contract (the user-facing entry point is the task table above).
72
+
73
+ The release flow, the version states `main` moves through, and the tag and dispatch runbook live in [CONTRIBUTING.md](../CONTRIBUTING.md#release-flow).
74
+
75
+ ## Canonical owners
76
+
77
+ Use this map when changing behavior or documentation. Update the owner first; other pages should summarize and link instead of copying full instructions.
78
+
79
+ | Fact or workflow | Canonical owner |
80
+ |---|---|
81
+ | Install and first successful run | [GETTING_STARTED.md](GETTING_STARTED.md) |
82
+ | Agent-operated install/configuration | [AGENT_SETUP.md](AGENT_SETUP.md) |
83
+ | Configuration keys and defaults | [CONFIGURATION_REFERENCE.md](CONFIGURATION_REFERENCE.md) |
84
+ | MCP server setup and callable tool surface | [MCP_SERVERS.md](MCP_SERVERS.md) |
85
+ | Agent tool-selection workflow | [AGENT_GUIDE.md](AGENT_GUIDE.md) |
86
+ | Console security and transports | [CONSOLE_MCP_SETUP.md](CONSOLE_MCP_SETUP.md) |
87
+ | v1-to-v2 migration | [UPGRADING_TO_2.md](UPGRADING_TO_2.md) |
88
+ | Failure diagnosis | [TROUBLESHOOTING.md](TROUBLESHOOTING.md) |
89
+ | Contributor policy | [CONTRIBUTING.md](../CONTRIBUTING.md) |
90
+ | Coding-agent repository instructions | [AGENTS.md](https://github.com/lost-in-the/woods/blob/main/AGENTS.md) |
91
+ | Non-MCP Ruby access to a published index | [PUBLISHED_INDEX.md](PUBLISHED_INDEX.md) |
92
+ | Evaluation harnesses | [EVALUATION.md](EVALUATION.md) |
93
+
94
+ The current public surface is generated from 35 extractors. Counts and capability claims must match `.Codex/release-v2/surface-inventory.json`, which is generated from the code and verified in CI.
@@ -0,0 +1,267 @@
1
+ # Retrieval Guide
2
+
3
+ Woods retrieval combines semantic search (vector similarity), keyword search (identifier/text matching), and graph traversal (dependency edges), fusing results with Reciprocal Rank Fusion (RRF) before assembling them into a token-budgeted context string. This is distinct from `search` (exact name/pattern lookup) or `lookup` (direct identifier fetch): retrieval is designed for natural-language questions about behavior, relationships, or concepts that span multiple code units.
4
+
5
+ ---
6
+
7
+ ## The Pipeline at a Glance
8
+
9
+ ```
10
+ query
11
+ └─▶ QueryClassifier classify intent, scope, target type
12
+ └─▶ SearchExecutor select strategy, run parallel search
13
+ ├── vector search (semantic similarity)
14
+ ├── keyword search (identifier/text matching)
15
+ └── graph traversal (dependency edges)
16
+ └─▶ Ranker RRF fusion + weighted signal scoring
17
+ └─▶ ContextAssembler token-budgeted context string
18
+ └─▶ RetrievalResult
19
+ ```
20
+
21
+ | Stage | Class | Responsibility |
22
+ |-------|-------|----------------|
23
+ | Classification | `Woods::Retrieval::QueryClassifier` | Detects intent, scope, target type, and framework context from the query text |
24
+ | Search | `Woods::Retrieval::SearchExecutor` | Maps classification to a strategy (`:vector`, `:keyword`, `:graph`, `:hybrid`, `:direct`) and executes it |
25
+ | Ranking | `Woods::Retrieval::Ranker` | Applies RRF across sources, then weighted signal scoring (semantic, keyword, recency, importance, type match, diversity) |
26
+ | Assembly | `Woods::Retrieval::ContextAssembler` | Fills a token budget with ranked units, sectioned into structural / primary / supporting / framework blocks |
27
+ | Orchestration | `Woods::Retriever` | Coordinates all four stages; returns a `RetrievalResult` with `context`, `sources`, `strategy`, `tokens_used`, and `trace` |
28
+
29
+ ### Search strategies
30
+
31
+ `SearchExecutor` selects one of five strategies based on query classification:
32
+
33
+ | Strategy | When selected | What it does |
34
+ |----------|--------------|-------------|
35
+ | `:vector` | `understand`, `debug`, `implement` intents | Embeds query, searches vector store by cosine similarity |
36
+ | `:keyword` | `locate`, `reference` intents; `framework` queries | Searches metadata store by extracted keywords |
37
+ | `:graph` | `trace` intent | Finds seed identifiers, then walks forward and reverse dependency edges |
38
+ | `:hybrid` | `comprehensive` or `exploratory` scope | Runs vector + keyword + graph expansion, deduplicates |
39
+ | `:direct` | `locate`/`reference` + `pinpoint` scope | Looks up identifiers directly in metadata store; falls back to keyword |
40
+
41
+ Keyword results are scored by how many distinct fields matched (identifier, source, metadata), not by the store's result order. Each matched field adds 0.25, capped at 1.0, so a result matching on identifier and source scores higher than one matching source alone.
42
+
43
+ ---
44
+
45
+ ## Configuring Retrieval
46
+
47
+ Retrieval requires an embedding provider and a vector store. Set these in `config/initializers/woods.rb`.
48
+
49
+ ### Presets (recommended)
50
+
51
+ Four named presets cover the supported deployment scenarios:
52
+
53
+ ```ruby
54
+ # Local development: in-memory vectors + SQLite metadata + Ollama.
55
+ # Requires sqlite3, a running Ollama service, and a pulled embedding model.
56
+ Woods.configure_with_preset(:local)
57
+
58
+ # Separate embed/query processes sharing output_dir. No sqlite3 gem.
59
+ # Requires a running Ollama service and a filesystem visible to both processes.
60
+ Woods.configure_with_preset(:shared_filesystem)
61
+
62
+ # PostgreSQL: pgvector + SQLite metadata + OpenAI.
63
+ # Requires pgvector, sqlite3, and an OpenAI API key.
64
+ Woods.configure_with_preset(:postgresql) do |config|
65
+ config.embedding_options = { api_key: ENV.fetch('OPENAI_API_KEY') }
66
+ config.vector_store_options = { connection: ActiveRecord::Base.connection }
67
+ end
68
+
69
+ # Production: Qdrant + SQLite metadata + OpenAI.
70
+ # Requires Qdrant, sqlite3, and an OpenAI API key.
71
+ Woods.configure_with_preset(:production) do |config|
72
+ config.embedding_options = { api_key: ENV.fetch('OPENAI_API_KEY') }
73
+ config.vector_store_options = {
74
+ url: ENV.fetch('QDRANT_URL'),
75
+ collection: ENV.fetch('WOODS_QDRANT_COLLECTION', 'woods'),
76
+ allow_private_hosts: true # only when QDRANT_URL is deliberately private
77
+ }
78
+ end
79
+ ```
80
+
81
+ Presets accept a block for overrides:
82
+
83
+ ```ruby
84
+ Woods.configure_with_preset(:local) { |config| config.max_context_tokens = 12_000 }
85
+ ```
86
+
87
+ ### Manual configuration
88
+
89
+ **MySQL host app (Qdrant required. MySQL has no native vector extension):**
90
+
91
+ ```ruby
92
+ Woods.configure do |config|
93
+ config.vector_store = :qdrant
94
+ config.vector_store_options = {
95
+ url: ENV.fetch('QDRANT_URL'),
96
+ collection: 'myapp',
97
+ allow_private_hosts: true # required for trusted localhost/RFC1918 URLs
98
+ }
99
+ config.metadata_store = :sqlite
100
+ config.embedding_provider = :openai
101
+ config.embedding_options = { api_key: ENV['OPENAI_API_KEY'] }
102
+ config.embedding_model = 'text-embedding-3-small'
103
+ end
104
+ ```
105
+
106
+ **PostgreSQL host app (pgvector, all-in-one):**
107
+
108
+ ```ruby
109
+ Woods.configure do |config|
110
+ config.vector_store = :pgvector
111
+ # pgvector takes a live PostgreSQL connection object, not a URL.
112
+ config.vector_store_options = { connection: ActiveRecord::Base.connection }
113
+ config.metadata_store = :sqlite
114
+ config.embedding_provider = :openai
115
+ config.embedding_options = { api_key: ENV['OPENAI_API_KEY'] }
116
+ config.embedding_model = 'text-embedding-3-small'
117
+ end
118
+ ```
119
+
120
+ After configuring, generate embeddings before running retrieval:
121
+
122
+ ```bash
123
+ bundle exec rake woods:extract
124
+ bundle exec rake woods:embed
125
+ ```
126
+
127
+ ---
128
+
129
+ ## Running Retrieval
130
+
131
+ ### MCP tool: `codebase_retrieve`
132
+
133
+ The primary interface for agents. Available in the Index Server when an embedding provider is configured and `rake woods:embed` has been run.
134
+
135
+ ```
136
+ codebase_retrieve(query: "how does billing work?")
137
+ codebase_retrieve(query: "what callbacks run when an order is placed?", budget: 12000)
138
+ ```
139
+
140
+ Parameters:
141
+
142
+ | Parameter | Type | Default | Description |
143
+ |-----------|------|---------|-------------|
144
+ | `query` | string | required | Natural-language question |
145
+ | `budget` | integer | 8000 | Token budget for context assembly |
146
+
147
+ The tool returns a formatted context string ready for use in a prompt, along with source attributions. Use `search` for exact name/pattern lookups; use `codebase_retrieve` for conceptual or behavioral questions.
148
+
149
+ ### Ruby API
150
+
151
+ ```ruby
152
+ retriever = Woods::Retriever.new(
153
+ vector_store: vector_store,
154
+ metadata_store: metadata_store,
155
+ graph_store: graph_store,
156
+ embedding_provider: embedding_provider
157
+ )
158
+
159
+ result = retriever.retrieve("How does the User model work?")
160
+
161
+ result.context # => "Codebase: 42 units...\n\n---\n\n## User (model)\n..."
162
+ result.strategy # => :hybrid
163
+ result.tokens_used # => 4200
164
+ result.sources # => [{ identifier: "User", type: "model", score: 0.91, ... }]
165
+ result.trace # => RetrievalTrace with elapsed_ms, candidate_count, etc.
166
+ ```
167
+
168
+ Override the token budget per call:
169
+
170
+ ```ruby
171
+ result = retriever.retrieve("explain the checkout flow", budget: 16_000)
172
+ ```
173
+
174
+ `Woods.build_retriever` instantiates a retriever from the current configuration:
175
+
176
+ ```ruby
177
+ Woods.configure_with_preset(:postgresql) do |config|
178
+ config.embedding_options = { api_key: ENV.fetch('OPENAI_API_KEY') }
179
+ config.vector_store_options = { connection: ActiveRecord::Base.connection }
180
+ end
181
+ retriever = Woods.build_retriever
182
+ result = retriever.retrieve("what validations does Order have?")
183
+ ```
184
+
185
+ ---
186
+
187
+ ## Degradation Tiers
188
+
189
+ Retrieval degrades gracefully when components are unavailable. The Retriever itself does not implement explicit fallback tiers, degradation happens naturally through how each component handles errors:
190
+
191
+ - **Embedding provider unavailable**: `codebase_retrieve` returns a structured configuration error. Check `woods_status` for retrieval readiness.
192
+ - **Vector store unavailable**: vector and hybrid strategies fail at query time. Keyword and graph strategies remain available for direct calls to `SearchExecutor`.
193
+ - **Metadata store error**: the structural context overview (unit counts by type) is silently omitted; `Retriever#build_structural_context` rescues `StandardError` and returns `nil`. The retrieval result is still returned without the overview.
194
+ - **Graph store unavailable**: graph expansion in hybrid strategy produces no graph candidates; vector and keyword candidates are still ranked and returned.
195
+
196
+ In all cases, errors in individual components produce empty candidate sets for that source rather than raising through the `Retriever`. Configure circuit breakers via `Woods::Resilience::CircuitBreaker` on external providers (Qdrant, OpenAI) for production deployments.
197
+
198
+ ---
199
+
200
+ ## Tuning
201
+
202
+ ### `similarity_threshold`
203
+
204
+ Controls which vector search results are considered. Range: `0.0`–`1.0`. Default: `0.7`.
205
+
206
+ ```ruby
207
+ config.similarity_threshold = 0.6 # Include less similar results (broader)
208
+ config.similarity_threshold = 0.8 # Require higher similarity (narrower)
209
+ ```
210
+
211
+ Lower values return more candidates, which can improve recall for broad queries at the cost of precision. Raise it if results seem loosely related.
212
+
213
+ ### `max_context_tokens`
214
+
215
+ Sets the default token budget for context assembly. Default: `8000`. The `budget` parameter on `codebase_retrieve` and `Retriever#retrieve` overrides this per call.
216
+
217
+ ```ruby
218
+ config.max_context_tokens = 12_000 # More context per retrieval
219
+ ```
220
+
221
+ The `ContextAssembler` carves off 10% for the structural overview first, then splits what's left:
222
+
223
+ - **Framework context active** (the query mentions Rails/framework keywords, `rails`, `activerecord`, `middleware`, etc.): primary 55%, supporting 25%, framework 20%.
224
+ - **No framework context**: primary 65%, supporting 35%, the framework section gets nothing, and its share is not proportionally folded into the other two; the fractions are just different, not rescaled.
225
+
226
+ Separately, if the supporting section ends up with no candidates (it only ever holds `:graph_expansion` results), its reserved budget is reclaimed into primary rather than wasted.
227
+
228
+ ### `context_format`
229
+
230
+ Controls how assembled units are formatted. Default: `:markdown`. Valid values: `:claude`, `:markdown`, `:plain`, `:json`.
231
+
232
+ ```ruby
233
+ config.context_format = :claude # XML-wrapped output for Claude-style context
234
+ config.context_format = :json # Machine-readable output
235
+ ```
236
+
237
+ ### Switching embedding models
238
+
239
+ The embedding model must match between `rake woods:embed` and retrieval. Different models produce vectors with different dimensionalities. Woods raises `Woods::MCP::DimensionMismatch` when they disagree, at embed time for durable stores and at MCP boot for dumps. After changing `embedding_model`, drop the vector store and re-run full extraction and embedding:
240
+
241
+ ```bash
242
+ bundle exec rake woods:extract
243
+ bundle exec rake woods:embed
244
+ ```
245
+
246
+ **OpenAI model dimensions:**
247
+
248
+ | Model | Dimensions |
249
+ |-------|-----------|
250
+ | `text-embedding-3-small` (default) | 1536 |
251
+ | `text-embedding-3-large` | 3072 |
252
+
253
+ **Ollama default model:** `nomic-embed-text`. Dimensions are detected dynamically on first embed.
254
+
255
+ ---
256
+
257
+ ## Troubleshooting
258
+
259
+ | Symptom | Likely cause | Fix |
260
+ |---------|-------------|-----|
261
+ | `codebase_retrieve` returns no results | Embeddings not generated, or embedding provider not configured | Run `rake woods:embed`; verify `embedding_provider` is set and API key is valid |
262
+ | Results are stale or missing recent changes | Index not updated after code changes | Run `rake woods:incremental` (or `rake woods:extract` for route/event changes) |
263
+ | Dimension mismatch warning in logs | `embedding_model` changed after embedding was generated | Re-run `rake woods:extract && rake woods:embed` with the new model |
264
+ | Empty results for a known class name | Keyword strategy not finding the identifier | Try a conceptual query with `codebase_retrieve`; or use `search` for exact name lookup |
265
+ | Very slow retrieval | Large vector index without HNSW index, or Qdrant cold start | For pgvector: create an HNSW index (see `BACKEND_MATRIX.md`). For Qdrant: check collection status |
266
+ | `codebase_retrieve` tool listed but disabled | Embedding provider not configured or API key missing | Set `embedding_provider`, run `woods:embed`, and check `woods_status` |
267
+ | Results clustered around one type | Diversity penalty insufficient for codebase shape | Lower `similarity_threshold` slightly and widen the query scope |
@@ -0,0 +1,68 @@
1
+ # Token Estimation Benchmark
2
+
3
+ > **Single source of truth:** `Woods::TokenUtils.chars_per_token_for(provider)`
4
+ > in `lib/woods/token_utils.rb`. Production code uses **4.0 chars/token** for
5
+ > the OpenAI path and **1.5 chars/token** for the Ollama / WordPiece path.
6
+ > Both are applied consistently by `Woods::Builder#chars_per_token_for`,
7
+ > `ContextAssembler`, `TextPreparer`, and `ExtractedUnit#estimated_tokens`.
8
+ > The cost-model layer (`lib/woods/cost_model/`) is the one exception, it
9
+ > uses its own pre-aggregated `TOKENS_PER_CHUNK` constant (450) rather than
10
+ > a per-string ratio, since it measures a different thing (per-chunk average
11
+ > vs. per-string chars/token).
12
+ >
13
+ > When the optional [`tokenizers`](https://github.com/ankane/tokenizers-ruby)
14
+ > gem is installed, the Ollama path uses the real BERT WordPiece tokenizer
15
+ > (`Woods::Embedding::TokenCounter`) instead of this heuristic. The 4.0
16
+ > divisor below is what the gem falls back to everywhere else.
17
+
18
+ This is a historical record of the benchmark that picked 4.0 over the
19
+ original 3.5 divisor. It is cited from five places in `lib/` as the evidence
20
+ for that choice, keep the numbers below intact if you edit this doc.
21
+
22
+ ## What was measured
23
+
24
+ - **Corpus**: 19 Ruby source files from `lib/woods/` (1.4 KB–33.4 KB): extractors, retrieval pipeline, MCP servers, and utility modules.
25
+ - **Reference tokenizer**: tiktoken_ruby with cl100k_base (GPT-4) and o200k_base (GPT-4o).
26
+ - **Heuristic under test**: `(content.length / N).ceil`, comparing divisors 3.0–4.0.
27
+
28
+ ## Results
29
+
30
+ | Divisor | Mean Abs Error (cl100k) | Max Abs Error |
31
+ |--------:|------------------------:|--------------:|
32
+ | 3.0 | 47.2% | 80.5% |
33
+ | 3.2 | 38.0% | 69.2% |
34
+ | 3.5 (previous default) | 26.2% | 54.7% |
35
+ | 3.8 | 16.2% | 42.5% |
36
+ | **4.0 (shipped)** | **10.6%** | **35.4%** |
37
+
38
+ Mean chars/token across the corpus was **4.41** (range 3.94–5.42). The
39
+ heuristic always overestimated, never underestimated, across all 19 files,
40
+ which is what makes it safe for token-limit enforcement even at its worst
41
+ case. Code lines and comment/YARD lines had similar ratios (4.38 vs. 4.27
42
+ chars/token), no separate handling needed for either.
43
+
44
+ ## What shipped
45
+
46
+ **The divisor changed from 3.5 to 4.0.** It roughly halves the mean
47
+ overestimate (26.2% → 10.6%) while keeping the conservative
48
+ always-overestimates property, at zero new runtime dependencies. The
49
+ constant lives in one place now (`Woods::TokenUtils::CHARS_PER_TOKEN_BY_PROVIDER`),
50
+ not scattered across call sites, see `lib/woods/token_utils.rb` for the
51
+ current definition and `docs/EMBEDDING_MODELS.md` for the Ollama-side ratio.
52
+
53
+ **tiktoken_ruby was deliberately not added as a runtime dependency.** A 10.6%
54
+ mean error is acceptable for chunking decisions, budget estimates, and
55
+ truncation; a native-extension dependency for marginal accuracy gains wasn't
56
+ worth it. The optional `tokenizers` gem covers the case where exact counts
57
+ matter more (see above).
58
+
59
+ ## Reproducing this benchmark
60
+
61
+ ```bash
62
+ gem install tiktoken_ruby # not a Gemfile dependency, benchmark only
63
+ ```
64
+
65
+ `spec/token_estimation_benchmark_spec.rb` keeps this honest going forward: it
66
+ checks the heuristic is self-consistent (monotonically increasing with
67
+ content length) and, when tiktoken_ruby is installed, that overestimation
68
+ stays bounded with no underestimation greater than 5%.