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
data/lib/woods.rb CHANGED
@@ -22,6 +22,10 @@
22
22
  # end
23
23
  #
24
24
  require_relative 'woods/version'
25
+ # Configuration's `component_paths` default lives with the discovery it feeds.
26
+ require_relative 'woods/extractors/component_discovery'
27
+ # Configuration defaults the cycle caps to the analyzer's own constants.
28
+ require_relative 'woods/graph_analyzer'
25
29
 
26
30
  module Woods
27
31
  class Error < StandardError; end
@@ -120,8 +124,13 @@ module Woods
120
124
  # ════════════════════════════════════════════════════════════════════════
121
125
 
122
126
  class Configuration # rubocop:disable Metrics/ClassLength
123
- attr_accessor :embedding_model, :include_framework_sources, :gem_configs,
124
- :vector_store, :metadata_store, :graph_store, :embedding_provider, :log_level,
127
+ # The set every full extraction always runs, regardless of `extractors=`.
128
+ # See the {#extractors=} YARD note: this knob does not gate extraction.
129
+ DEFAULT_EXTRACTORS = %i[models controllers services components view_components jobs mailers graphql
130
+ serializers managers policies validators rails_source].freeze
131
+
132
+ attr_accessor :include_framework_sources, :gem_configs,
133
+ :vector_store, :metadata_store, :graph_store, :embedding_provider,
125
134
  :vector_store_options, :metadata_store_options, :embedding_options,
126
135
  :concurrent_extraction, :precompute_flows, :extract_navigation_edges, :enable_snapshots,
127
136
  :session_tracer_enabled, :session_tracer_allow_production,
@@ -137,19 +146,21 @@ module Woods
137
146
  :notion_api_token, :notion_database_ids,
138
147
  :unblocked_api_token, :unblocked_collection_id, :unblocked_repo_url,
139
148
  :cache_store, :cache_options,
140
- :dump_retention_count
141
- attr_reader :max_context_tokens, :similarity_threshold, :extractors, :pretty_json, :context_format,
142
- :cache_enabled
149
+ :dump_retention_count, :component_paths
150
+ attr_reader :embedding_model, :max_context_tokens, :similarity_threshold, :extractors, :pretty_json,
151
+ :context_format, :cache_enabled, :volatile_dependency_ratio,
152
+ :graph_cycle_limit, :graph_cycle_max_length,
153
+ :incremental_blast_radius_depth, :durable_payload_writes
143
154
 
144
155
  def initialize # rubocop:disable Metrics/MethodLength
145
156
  @output_dir = nil # Resolved lazily; Rails.root is nil at require time
146
157
  @embedding_model = 'text-embedding-3-small'
158
+ @embedding_model_explicit = false
147
159
  @max_context_tokens = 8000
148
160
  @similarity_threshold = 0.7
149
161
  @include_framework_sources = true
150
162
  @gem_configs = {}
151
- @extractors = %i[models controllers services components view_components jobs mailers graphql serializers
152
- managers policies validators rails_source]
163
+ @extractors = DEFAULT_EXTRACTORS.dup
153
164
  @pretty_json = true
154
165
  @concurrent_extraction = false
155
166
  @precompute_flows = false
@@ -197,6 +208,28 @@ module Woods
197
208
  @cache_store = nil # :redis, :solid_cache, :memory, or a CacheStore instance
198
209
  @cache_options = {} # { redis: client, cache: store, ttl: { embeddings: 86400, ... } }
199
210
  @dump_retention_count = 3
211
+ @volatile_dependency_ratio = 3.0
212
+ @graph_cycle_limit = GraphAnalyzer::DEFAULT_CYCLE_LIMIT
213
+ @graph_cycle_max_length = GraphAnalyzer::DEFAULT_CYCLE_MAX_LENGTH
214
+ # nil = the unbounded transitive closure. See the setter for why the
215
+ # default is not a small number.
216
+ @incremental_blast_radius_depth = nil
217
+ # false = payload files are written without a per-file fsync and the
218
+ # whole payload is flushed once before `generation.json` is written.
219
+ # See the setter.
220
+ @durable_payload_writes = false
221
+ # Directories the component extractors walk before reading `descendants`,
222
+ # relative to Rails.root. See Extractors::ComponentDiscovery.
223
+ @component_paths = Extractors::ComponentDiscovery::DEFAULT_COMPONENT_PATHS.dup
224
+ end
225
+
226
+ def embedding_model=(value)
227
+ @embedding_model = value
228
+ @embedding_model_explicit = true
229
+ end
230
+
231
+ def embedding_model_explicit?
232
+ @embedding_model_explicit
200
233
  end
201
234
 
202
235
  # @return [Pathname, String] Output directory, defaulting to Rails.root/tmp/woods
@@ -235,6 +268,10 @@ module Woods
235
268
  @similarity_threshold = float_val
236
269
  end
237
270
 
271
+ # Accepted for forward compatibility. Extraction always runs all 34
272
+ # extractors; this array is not consulted anywhere in the extraction
273
+ # path. Setting it to anything other than {DEFAULT_EXTRACTORS} warns.
274
+ #
238
275
  # @param value [Array<Symbol>] List of extractor names
239
276
  # @raise [ConfigurationError] if value is not an Array of Symbols
240
277
  def extractors=(value)
@@ -242,9 +279,37 @@ module Woods
242
279
  raise ConfigurationError, "extractors must be an Array of Symbols, got #{value.inspect}"
243
280
  end
244
281
 
282
+ if value.sort_by(&:to_s) != DEFAULT_EXTRACTORS.sort_by(&:to_s)
283
+ warn 'config.extractors is accepted for forward compatibility but extractor selection is not ' \
284
+ 'implemented; all extractors run.'
285
+ end
286
+
245
287
  @extractors = value
246
288
  end
247
289
 
290
+ # Force an fsync on every payload file as it is written, on top of the
291
+ # single flush the publish already performs.
292
+ #
293
+ # Off by default, and off is not a weaker guarantee. Readers resolve only
294
+ # through `generation.json`; a payload file has no reader until that
295
+ # pointer names it, and {Woods::Extractor#sync_payload} makes the whole
296
+ # payload durable before the pointer is written. Turning this on buys
297
+ # exactly one thing: an individual payload file being durable before the
298
+ # pointer exists.
299
+ #
300
+ # It costs a lot for it. Two forced flushes per file, 8.9ms each on btrfs:
301
+ # 8000 units is 71s of writing against 1s.
302
+ #
303
+ # Turning it on cannot disable the publish flush. That flush *is* the
304
+ # durability contract, so it has no opt-out.
305
+ #
306
+ # @param value [Boolean]
307
+ # @raise [Woods::ConfigurationError] when value is not a boolean
308
+ def durable_payload_writes=(value)
309
+ validate_boolean!(:durable_payload_writes, value)
310
+ @durable_payload_writes = value
311
+ end
312
+
248
313
  # @param value [Boolean] Must be true or false
249
314
  # @raise [ConfigurationError] if value is not a boolean
250
315
  def pretty_json=(value)
@@ -270,17 +335,81 @@ module Woods
270
335
  @cache_enabled = value
271
336
  end
272
337
 
273
- # Add a gem to be indexed
338
+ # Commit-count ratio above which {Woods::GraphAnalyzer#volatile_dependencies}
339
+ # reports an edge. Stored as a Float.
340
+ #
341
+ # @param value [Numeric] must be greater than 1
342
+ # @raise [ConfigurationError] otherwise
343
+ def volatile_dependency_ratio=(value)
344
+ unless value.is_a?(Numeric) && value > 1
345
+ raise ConfigurationError, "volatile_dependency_ratio must be a number greater than 1, got #{value.inspect}"
346
+ end
347
+
348
+ @volatile_dependency_ratio = value.to_f
349
+ end
350
+
351
+ # How many distinct cycles {Woods::GraphAnalyzer#cycles} enumerates before
352
+ # it stops. `nil` removes the cap and restores exhaustive enumeration.
353
+ #
354
+ # @param value [Integer, nil] must be a positive Integer, or nil
355
+ # @raise [ConfigurationError] otherwise
356
+ def graph_cycle_limit=(value)
357
+ @graph_cycle_limit = validate_optional_positive_integer!(:graph_cycle_limit, value)
358
+ end
359
+
360
+ # The longest cycle {Woods::GraphAnalyzer#cycles} records, in distinct
361
+ # nodes. `nil` removes the cap.
362
+ #
363
+ # @param value [Integer, nil] must be a positive Integer, or nil
364
+ # @raise [ConfigurationError] otherwise
365
+ def graph_cycle_max_length=(value)
366
+ @graph_cycle_max_length = validate_optional_positive_integer!(:graph_cycle_max_length, value)
367
+ end
368
+
369
+ # How many reverse hops an incremental run walks from a changed file
370
+ # before it stops re-extracting dependents. `nil` (the default) keeps the
371
+ # unbounded transitive closure.
372
+ #
373
+ # A unit's extracted content is mostly a function of its own source and
374
+ # its own reflection, so on most graphs a small cap re-extracts the same
375
+ # bytes far faster. It is not safe everywhere, which is why the default
376
+ # is unbounded: an STI grandchild reads its grandparent's reflection
377
+ # (`reflect_on_all_associations`, `_validators` and the callback chain
378
+ # are all inherited), so `SportsCar < Car < Vehicle` has content that
379
+ # changes when `Vehicle` changes while sitting two hops away in the
380
+ # graph. A nested `has_many :through` resolves through the same kind of
381
+ # chain. Set this only on a tree you know has neither.
382
+ #
383
+ # @param value [Integer, nil] must be a positive Integer, or nil
384
+ # @raise [ConfigurationError] otherwise
385
+ def incremental_blast_radius_depth=(value)
386
+ @incremental_blast_radius_depth = validate_optional_positive_integer!(
387
+ :incremental_blast_radius_depth, value
388
+ )
389
+ end
390
+
391
+ # Accepted for forward compatibility. Nothing reads {gem_configs}; gem
392
+ # source indexing is not implemented.
274
393
  #
275
394
  # @param gem_name [String] Name of the gem
276
395
  # @param paths [Array<String>] Relative paths within the gem to index
277
396
  # @param priority [Symbol] :high, :medium, or :low
278
397
  def add_gem(gem_name, paths:, priority: :medium)
398
+ warn 'config.add_gem is accepted for forward compatibility but gem configs are not used.'
279
399
  @gem_configs[gem_name] = { paths: paths, priority: priority }
280
400
  end
281
401
 
282
402
  private
283
403
 
404
+ # @return [Integer, nil] +value+ when it is nil or a positive Integer
405
+ # @raise [ConfigurationError] otherwise
406
+ def validate_optional_positive_integer!(name, value)
407
+ return value if value.nil?
408
+ return value if value.is_a?(Integer) && value.positive?
409
+
410
+ raise ConfigurationError, "#{name} must be a positive Integer or nil, got #{value.inspect}"
411
+ end
412
+
284
413
  def validate_boolean!(name, value)
285
414
  return if value.is_a?(TrueClass) || value.is_a?(FalseClass)
286
415
 
@@ -305,7 +434,7 @@ module Woods
305
434
 
306
435
  # Configure the module using a named preset and optional block customization.
307
436
  #
308
- # Valid preset names: :local, :postgresql, :production
437
+ # Valid preset names: :local, :shared_filesystem, :postgresql, :production
309
438
  #
310
439
  # @param name [Symbol] Preset name
311
440
  # @yield [config] Optional block for further customization after preset is applied
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "woods-plugin",
3
+ "description": "Woods user guides for Claude Code: set up, upgrade, configure MCP servers for, investigate codebases with, enable a repository's agents on, and diagnose the Woods Rails code-intelligence gem.",
4
+ "version": "2.3.0",
5
+ "author": {
6
+ "name": "lost-in-the"
7
+ },
8
+ "homepage": "https://github.com/lost-in-the/woods",
9
+ "repository": "https://github.com/lost-in-the/woods",
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "rails",
13
+ "woods",
14
+ "mcp",
15
+ "code-intelligence",
16
+ "agent"
17
+ ]
18
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "hooks": {
3
+ "PostToolUse": [
4
+ {
5
+ "matcher": "Edit|Write|MultiEdit",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/woods-post-edit.sh\"",
10
+ "async": true,
11
+ "timeout": 600
12
+ }
13
+ ]
14
+ }
15
+ ],
16
+ "SessionStart": [
17
+ {
18
+ "matcher": "startup|resume",
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/woods-session-start.sh\"",
23
+ "timeout": 20
24
+ }
25
+ ]
26
+ }
27
+ ]
28
+ }
29
+ }
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env bash
2
+ # Woods PostToolUse hook (#280): after an edit to a graph-changing path,
3
+ # refresh the index with `woods:incremental` in the background.
4
+ #
5
+ # Reads `cwd` from the hook payload, not CLAUDE_PROJECT_DIR, so a session in
6
+ # a linked worktree refreshes that worktree's own index. Runs only when an
7
+ # index already exists there. `woods:incremental` stands down under a
8
+ # running watch daemon and takes the extraction lock itself; the lock here
9
+ # only stops this hook from queueing one rake process per keystroke.
10
+ #
11
+ # Opt-in: this hook is shipped disabled. It does nothing until
12
+ # WOODS_HOOKS_ENABLED=1 is set (see docs/WATCH_DAEMON.md and the woods-setup
13
+ # skill for where to set it). WOODS_HOOKS_DISABLED=1 turns it back off even
14
+ # after it has been enabled, without touching the enable setting.
15
+ #
16
+ # Lock contention must not drop an edit. A hook invocation that finds the
17
+ # run lock busy appends its path to hook-pending.txt and returns immediately
18
+ # instead of waiting; whichever invocation is holding the run lock drains
19
+ # that file in a loop, passing every path collected on each drain as one
20
+ # CHANGED_FILES batch, until a drain comes back empty. The one narrow gap
21
+ # this leaves: an append that lands after the holder's last (empty) drain
22
+ # but before it releases the run lock sits in the file, unprocessed, until
23
+ # the next graph-changing edit triggers a hook that both appends it and
24
+ # then wins the now-free run lock itself. That edit is delayed, not lost,
25
+ # and the SessionStart hook's staleness warning is the backstop for it.
26
+ #
27
+ # The mkdir-based lock fallback has no kernel-enforced release: a `flock`
28
+ # held by a killed process is freed automatically, but a lock *directory*
29
+ # a killed process made is not. A hook killed mid-drain (OOM, a `kill -9`,
30
+ # the host restarting) leaves `hook.lock.d` or `hook-pending.lock.d` behind
31
+ # forever, so every later hook either skips draining permanently (the run
32
+ # lock) or spins until the 600s hook timeout on every single invocation
33
+ # (the pending lock, which blocks). Both lock directories are therefore
34
+ # reclaimed once their mtime is older than WOODS_HOOK_LOCK_STALE_SECONDS: a
35
+ # fresh lock directory is still respected as busy.
36
+ #
37
+ # Knobs:
38
+ # WOODS_HOOK_RAKE command prefix, default "bundle exec rake"
39
+ # (Docker: "docker compose exec -T app bundle exec rake")
40
+ # WOODS_OUTPUT index directory override, same variable
41
+ # woods:incremental/woods:watch_status already read;
42
+ # default tmp/woods under the payload's cwd
43
+ # WOODS_HOOKS_ENABLED set to 1 to turn the hook on
44
+ # WOODS_HOOKS_DISABLED set to 1 to turn it back off
45
+ # WOODS_HOOK_LOCK_STALE_SECONDS
46
+ # age (mtime) after which a leftover mkdir-based lock
47
+ # directory is reclaimed instead of respected as busy;
48
+ # default 1800 (a few multiples of the 600s hook
49
+ # timeout). Only the mkdir fallback needs this;
50
+ # flock has no equivalent problem.
51
+ set -u
52
+
53
+ [ "${WOODS_HOOKS_DISABLED:-0}" = "1" ] && exit 0
54
+ [ "${WOODS_HOOKS_ENABLED:-0}" = "1" ] || exit 0
55
+
56
+ payload="$(cat)"
57
+
58
+ # $1: dotted key path. Prefers jq, falls back to ruby, else gives up quietly.
59
+ field() {
60
+ if command -v jq >/dev/null 2>&1; then
61
+ printf '%s' "$payload" | jq -r ".$1 // empty"
62
+ elif command -v ruby >/dev/null 2>&1; then
63
+ printf '%s' "$payload" | ruby -rjson -e '
64
+ value = JSON.parse($stdin.read)
65
+ ARGV[0].split(".").each { |key| value = value.is_a?(Hash) ? value[key] : nil }
66
+ print value.to_s' -- "$1"
67
+ else
68
+ printf ''
69
+ fi
70
+ }
71
+
72
+ cwd="$(field cwd)"
73
+ file="$(field tool_input.file_path)"
74
+ [ -z "$cwd" ] && exit 0
75
+ [ -z "$file" ] && exit 0
76
+
77
+ # Same no-boot resolution woods:watch_status uses: an explicit WOODS_OUTPUT
78
+ # wins outright (absolute or relative to cwd), otherwise tmp/woods under cwd.
79
+ # This is the config's own override knob, not a second hardcoded path.
80
+ configured_output="${WOODS_OUTPUT:-tmp/woods}"
81
+ case "$configured_output" in
82
+ /*) tmp_dir="$configured_output" ;;
83
+ *) tmp_dir="$cwd/$configured_output" ;;
84
+ esac
85
+
86
+ [ -f "$tmp_dir/generation.json" ] || exit 0
87
+
88
+ case "$file" in
89
+ "$cwd"/*) rel="${file#"$cwd"/}" ;;
90
+ /*) exit 0 ;;
91
+ *) rel="$file" ;;
92
+ esac
93
+
94
+ case "$rel" in
95
+ app/models/*|config/routes.rb|config/routes/*|db/migrate/*|db/*_migrate/*|db/schema.rb|db/structure.sql|package.yml|*/package.yml|packwerk.yml) ;;
96
+ *) exit 0 ;;
97
+ esac
98
+
99
+ rake="${WOODS_HOOK_RAKE:-bundle exec rake}"
100
+ log="$tmp_dir/hook.log"
101
+ pending_file="$tmp_dir/hook-pending.txt"
102
+ pending_lock="$tmp_dir/hook-pending.lock"
103
+ pending_lock_dir="$tmp_dir/hook-pending.lock.d"
104
+ run_lock="$tmp_dir/hook.lock"
105
+ run_lock_dir="$tmp_dir/hook.lock.d"
106
+
107
+ mkdir -p "$tmp_dir" 2>/dev/null || true
108
+
109
+ have_flock() { command -v flock >/dev/null 2>&1; }
110
+
111
+ # mtime of a directory in epoch seconds, GNU stat then BSD stat, empty if
112
+ # neither exists (treated as "not stale": fail closed, keep waiting rather
113
+ # than reclaim on a guess).
114
+ dir_mtime() {
115
+ stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null
116
+ }
117
+
118
+ # True when $1 is an mkdir-based lock directory old enough that it can only
119
+ # be a crash leftover, never a legitimately still-running hook (a hook run
120
+ # is one rake invocation, bounded by the 600s hook timeout).
121
+ lock_dir_stale() {
122
+ mtime="$(dir_mtime "$1")"
123
+ [ -z "$mtime" ] && return 1
124
+ now="$(date +%s)"
125
+ age=$((now - mtime))
126
+ [ "$age" -gt "${WOODS_HOOK_LOCK_STALE_SECONDS:-1800}" ]
127
+ }
128
+
129
+ # Blocking mkdir-based lock acquire: waits for $1, reclaiming it once stale
130
+ # rather than waiting for a crashed holder that will never release it.
131
+ acquire_mkdir_lock() {
132
+ while ! mkdir "$1" 2>/dev/null; do
133
+ if lock_dir_stale "$1"; then
134
+ rmdir "$1" 2>/dev/null || true
135
+ continue
136
+ fi
137
+ sleep 0.1
138
+ done
139
+ }
140
+
141
+ release_mkdir_lock() {
142
+ rmdir "$1" 2>/dev/null || true
143
+ }
144
+
145
+ # Non-blocking mkdir-based lock attempt: one reclaim retry when stale, no
146
+ # wait otherwise (contention here means "someone else is already draining,"
147
+ # not "someone crashed").
148
+ try_acquire_mkdir_lock() {
149
+ mkdir "$1" 2>/dev/null && return 0
150
+ if lock_dir_stale "$1"; then
151
+ rmdir "$1" 2>/dev/null || true
152
+ mkdir "$1" 2>/dev/null && return 0
153
+ fi
154
+ return 1
155
+ }
156
+
157
+ # Append one path to the pending file. Blocking: the critical section is a
158
+ # single append, so any wait here is brief regardless of who else holds it.
159
+ append_pending() {
160
+ if have_flock; then
161
+ exec 7>"$pending_lock"
162
+ flock 7
163
+ printf '%s\n' "$1" >>"$pending_file"
164
+ flock -u 7
165
+ exec 7>&-
166
+ else
167
+ acquire_mkdir_lock "$pending_lock_dir"
168
+ printf '%s\n' "$1" >>"$pending_file"
169
+ release_mkdir_lock "$pending_lock_dir"
170
+ fi
171
+ }
172
+
173
+ # Print and clear whatever is currently pending, empty output if nothing is.
174
+ drain_pending() {
175
+ if have_flock; then
176
+ exec 7>"$pending_lock"
177
+ flock 7
178
+ if [ -s "$pending_file" ]; then
179
+ cat "$pending_file"
180
+ : >"$pending_file"
181
+ fi
182
+ flock -u 7
183
+ exec 7>&-
184
+ else
185
+ acquire_mkdir_lock "$pending_lock_dir"
186
+ if [ -s "$pending_file" ]; then
187
+ cat "$pending_file"
188
+ : >"$pending_file"
189
+ fi
190
+ release_mkdir_lock "$pending_lock_dir"
191
+ fi
192
+ }
193
+
194
+ run_incremental() {
195
+ # A drain can return several paths at once; CHANGED_FILES takes a
196
+ # comma-separated list (see lib/tasks/woods.rake).
197
+ changed="$(printf '%s\n' "$1" | tr '\n' ',' | sed 's/,$//')"
198
+ # shellcheck disable=SC2086
199
+ ( cd "$cwd" && CHANGED_FILES="$changed" $rake woods:incremental ) >>"$log" 2>&1
200
+ }
201
+
202
+ drain_until_empty() {
203
+ while :; do
204
+ batch="$(drain_pending)"
205
+ [ -z "$batch" ] && break
206
+ run_incremental "$batch"
207
+ done
208
+ }
209
+
210
+ append_pending "$rel"
211
+
212
+ if have_flock; then
213
+ exec 8>"$run_lock"
214
+ if flock -n 8; then
215
+ drain_until_empty
216
+ flock -u 8
217
+ fi
218
+ exec 8>&-
219
+ else
220
+ if try_acquire_mkdir_lock "$run_lock_dir"; then
221
+ drain_until_empty
222
+ release_mkdir_lock "$run_lock_dir"
223
+ fi
224
+ fi
225
+
226
+ exit 0
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+ # Woods SessionStart hook (#280): say so when the published generation is
3
+ # older than the last commit. Stdout from a SessionStart hook is added to
4
+ # the session context, so the agent sees the warning before it trusts the
5
+ # index.
6
+ #
7
+ # This check only compares two commit-adjacent timestamps: the generation's
8
+ # `updated_at` and `git log -1`'s commit time. It says nothing about
9
+ # uncommitted edits (the index can be stale against a dirty working tree
10
+ # with no stale commit to detect) or about a checkout sitting on an older
11
+ # commit than the one that produced the generation (the timestamp comparison
12
+ # can read as fresh there even though the code and the index disagree). Read
13
+ # a quiet run as "not behind the last commit," not as "definitely current."
14
+ #
15
+ # Opt-in: shipped disabled. Nothing prints until WOODS_HOOKS_ENABLED=1 is
16
+ # set; WOODS_HOOKS_DISABLED=1 turns it back off without touching that
17
+ # setting.
18
+ set -u
19
+
20
+ [ "${WOODS_HOOKS_DISABLED:-0}" = "1" ] && exit 0
21
+ [ "${WOODS_HOOKS_ENABLED:-0}" = "1" ] || exit 0
22
+
23
+ payload="$(cat)"
24
+
25
+ field() {
26
+ if command -v jq >/dev/null 2>&1; then
27
+ printf '%s' "$payload" | jq -r ".$1 // empty"
28
+ elif command -v ruby >/dev/null 2>&1; then
29
+ printf '%s' "$payload" | ruby -rjson -e '
30
+ value = JSON.parse($stdin.read)
31
+ ARGV[0].split(".").each { |key| value = value.is_a?(Hash) ? value[key] : nil }
32
+ print value.to_s' -- "$1"
33
+ else
34
+ printf ''
35
+ fi
36
+ }
37
+
38
+ cwd="$(field cwd)"
39
+ [ -z "$cwd" ] && exit 0
40
+
41
+ # Same no-boot resolution the PostToolUse hook and woods:watch_status use:
42
+ # WOODS_OUTPUT overrides outright, otherwise tmp/woods under cwd.
43
+ configured_output="${WOODS_OUTPUT:-tmp/woods}"
44
+ case "$configured_output" in
45
+ /*) tmp_dir="$configured_output" ;;
46
+ *) tmp_dir="$cwd/$configured_output" ;;
47
+ esac
48
+
49
+ marker="$tmp_dir/generation.json"
50
+ [ -f "$marker" ] || exit 0
51
+
52
+ if command -v jq >/dev/null 2>&1; then
53
+ updated="$(jq -r '.updated_at // empty' "$marker")"
54
+ number="$(jq -r '.number // empty' "$marker")"
55
+ elif command -v ruby >/dev/null 2>&1; then
56
+ updated="$(ruby -rjson -e 'print JSON.parse(File.read(ARGV[0]))["updated_at"].to_s' -- "$marker")"
57
+ number="$(ruby -rjson -e 'print JSON.parse(File.read(ARGV[0]))["number"].to_s' -- "$marker")"
58
+ else
59
+ exit 0
60
+ fi
61
+ [ -z "$updated" ] && exit 0
62
+
63
+ last_commit="$(git -C "$cwd" log -1 --format=%cI 2>/dev/null)"
64
+ [ -z "$last_commit" ] && exit 0
65
+
66
+ stale=0
67
+ if command -v ruby >/dev/null 2>&1; then
68
+ ruby -rtime -e 'exit(Time.parse(ARGV[0]) < Time.parse(ARGV[1]) ? 1 : 0)' -- "$updated" "$last_commit" || stale=1
69
+ elif date -d "$updated" +%s >/dev/null 2>&1; then
70
+ [ "$(date -d "$updated" +%s)" -lt "$(date -d "$last_commit" +%s)" ] && stale=1
71
+ fi
72
+
73
+ if [ "$stale" = "1" ]; then
74
+ echo "Woods index is stale: generation ${number:-?} was published at $updated, before the last commit at $last_commit." \
75
+ "Run bin/rails woods:incremental (or start bin/rails woods:watch) before trusting woods answers."
76
+ fi
77
+ exit 0
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: woods-agent-enable
3
+ description: Make a repository's coding agents use the Woods index by default — wire the MCP server into project configuration, add index-first guidance to CLAUDE.md/AGENTS.md, and optionally create a project skill. Use when a user wants agents to "know" their Rails codebase, asks to add Woods to a team's agent setup or repository instructions, or wants codebase intelligence available in every session without per-user installs.
4
+ ---
5
+
6
+ # Enable Woods for a repository's agents
7
+
8
+ Installing the gem gives one operator an index; this skill gives every future agent session one. Wiring is three small, reviewable changes to the host repository. Each edits shared, checked-in files, so propose the diff and get approval before writing, match the repository's existing conventions, and leave unrelated content untouched.
9
+
10
+ ## Preflight
11
+
12
+ Confirm Woods actually works before advertising it to every future session:
13
+
14
+ ```bash
15
+ bundle info woods
16
+ bin/rails woods:validate
17
+ bin/rails woods:stats
18
+ ```
19
+
20
+ A missing gem or index means setup comes first (woods-setup). Record the index path and whether the app runs on the host or in Docker — the MCP entry must use the command shape that matches (woods-mcp-config has the shapes).
21
+
22
+ ## 1. Project MCP configuration
23
+
24
+ Add the Index Server to the repository's checked-in MCP configuration (`.mcp.json` for Claude Code; adapt to the team's client) using the Index-only shape from woods-mcp-config, so every clone gets the server without per-user setup. Do not wire Console MCP at the project level: it is live-data access and stays a per-user, explicitly authorized opt-in.
25
+
26
+ ## 2. Index-first guidance in the agent instructions
27
+
28
+ Add a short section to the repository's agent instructions (CLAUDE.md, AGENTS.md, or the team's equivalent). Keep it to the contract, not a tool manual — the connected server's own tool list is the manual:
29
+
30
+ ```markdown
31
+ ## Codebase index (Woods)
32
+
33
+ A Woods MCP server ("woods") serves a runtime-accurate index of this app.
34
+ Call `woods_status` first; for structural questions prefer `search` →
35
+ `lookup` → `dependencies`/`dependents`/`trace_flow` over broad file reading
36
+ or grep. "Not found" is evidence about the index, not the code — check
37
+ generation freshness before concluding. After changing files, refresh with
38
+ `bin/rails woods:incremental` (or run the `woods:watch` daemon).
39
+ ```
40
+
41
+ Adjust names and commands to the app (Docker prefix, custom index path, task aliases). If the repository already documents agent tooling, extend that section rather than adding a competing one.
42
+
43
+ ## 3. Optional: a project skill
44
+
45
+ When the team wants stronger triggering than standing instructions provide, create a repository-level skill (for Claude Code: `.claude/skills/<app>-codebase/SKILL.md`) whose description names the application and its domains, and whose body applies the woods-investigate workflow to this codebase specifically: the entry-point identifiers worth knowing, domain cluster names, and local conventions. Seed that content from real `domain_clusters` and `pagerank` output rather than inventing it, and note in the skill that the index — not the skill text — is the source of truth as the app evolves.
46
+
47
+ ## Handoff
48
+
49
+ Report the files changed, the MCP entry added, and one verified end-to-end call (`woods_status` through the configured client, not a raw JSON-RPC probe). Remind the owner that agent sessions pick up the changes on their next start, and that the index only stays useful if extraction stays current.
50
+
51
+ Canonical guides: [AGENT_SETUP.md](https://github.com/lost-in-the/woods/blob/main/docs/AGENT_SETUP.md), [AGENT_GUIDE.md](https://github.com/lost-in-the/woods/blob/main/docs/AGENT_GUIDE.md).