woods 2.0.0.beta2 → 2.0.0.beta4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +339 -1
  3. data/CONTRIBUTING.md +188 -12
  4. data/README.md +93 -174
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +109 -8
  7. data/docs/AGENT_SETUP.md +98 -7
  8. data/docs/BACKEND_MATRIX.md +25 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +267 -16
  11. data/docs/CONSOLE_MCP_SETUP.md +80 -7
  12. data/docs/DOCKER_SETUP.md +22 -3
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +45 -6
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +147 -7
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +276 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +37 -22
  22. data/docs/MCP_WORKTREE_SETUP.md +43 -83
  23. data/docs/NOTION_INTEGRATION.md +13 -0
  24. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  25. data/docs/PUBLISHED_INDEX.md +72 -0
  26. data/docs/README.md +7 -0
  27. data/docs/RETRIEVAL_GUIDE.md +273 -12
  28. data/docs/RUNTIME_TRACING.md +71 -0
  29. data/docs/SOURCE_FRESHNESS.md +143 -0
  30. data/docs/TROUBLESHOOTING.md +129 -18
  31. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  32. data/docs/UPGRADING_TO_2.md +48 -22
  33. data/docs/WATCH_DAEMON.md +277 -67
  34. data/exe/woods-agent-config +6 -0
  35. data/exe/woods-extract +5 -0
  36. data/exe/woods-hook-context +6 -0
  37. data/exe/woods-mcp-start +14 -9
  38. data/lib/generators/woods/pgvector_generator.rb +8 -2
  39. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  40. data/lib/tasks/woods.rake +47 -397
  41. data/lib/woods/agent_configuration/applier.rb +135 -0
  42. data/lib/woods/agent_configuration/cli.rb +101 -0
  43. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  44. data/lib/woods/agent_configuration/document.rb +105 -0
  45. data/lib/woods/agent_configuration/error.rb +7 -0
  46. data/lib/woods/agent_configuration/launcher.rb +75 -0
  47. data/lib/woods/agent_configuration/layout.rb +72 -0
  48. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  49. data/lib/woods/agent_configuration/plan.rb +98 -0
  50. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  51. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  52. data/lib/woods/agent_configuration/planner.rb +63 -0
  53. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  54. data/lib/woods/agent_configuration/preflight.rb +100 -0
  55. data/lib/woods/agent_configuration/recovery.rb +49 -0
  56. data/lib/woods/ast/node.rb +2 -0
  57. data/lib/woods/ast/parser.rb +38 -5
  58. data/lib/woods/builder.rb +21 -5
  59. data/lib/woods/cache/cache_middleware.rb +28 -7
  60. data/lib/woods/cache/cache_store.rb +4 -5
  61. data/lib/woods/change_set.rb +5 -4
  62. data/lib/woods/console/credential_index.rb +20 -2
  63. data/lib/woods/console/credential_scanner.rb +18 -17
  64. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  65. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  66. data/lib/woods/console/embedded_executor.rb +32 -10
  67. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  68. data/lib/woods/console/rack_middleware.rb +22 -13
  69. data/lib/woods/console/server.rb +18 -16
  70. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  71. data/lib/woods/console/sql_table_scanner.rb +47 -7
  72. data/lib/woods/console/sql_validator.rb +49 -9
  73. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  74. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  75. data/lib/woods/dependency_graph.rb +65 -13
  76. data/lib/woods/embedding/corpus.rb +94 -0
  77. data/lib/woods/embedding/indexer.rb +114 -60
  78. data/lib/woods/embedding/openai.rb +17 -6
  79. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  80. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  81. data/lib/woods/export/typed_reader.rb +56 -0
  82. data/lib/woods/extractor.rb +277 -149
  83. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  84. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  85. data/lib/woods/extractors/caching_extractor.rb +3 -1
  86. data/lib/woods/extractors/concern_extractor.rb +64 -6
  87. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  88. data/lib/woods/extractors/controller_extractor.rb +13 -4
  89. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  90. data/lib/woods/extractors/declared_parent.rb +55 -0
  91. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  92. data/lib/woods/extractors/engine_extractor.rb +3 -1
  93. data/lib/woods/extractors/event_extractor.rb +4 -2
  94. data/lib/woods/extractors/factory_extractor.rb +3 -1
  95. data/lib/woods/extractors/graphql_extractor.rb +10 -13
  96. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  97. data/lib/woods/extractors/job_extractor.rb +6 -19
  98. data/lib/woods/extractors/lib_extractor.rb +13 -9
  99. data/lib/woods/extractors/mailer_extractor.rb +26 -15
  100. data/lib/woods/extractors/manager_extractor.rb +3 -1
  101. data/lib/woods/extractors/method_parameters.rb +53 -0
  102. data/lib/woods/extractors/middleware_argument.rb +65 -0
  103. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  104. data/lib/woods/extractors/migration_extractor.rb +3 -1
  105. data/lib/woods/extractors/model_extractor.rb +26 -34
  106. data/lib/woods/extractors/package_extractor.rb +24 -4
  107. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  108. data/lib/woods/extractors/policy_extractor.rb +3 -1
  109. data/lib/woods/extractors/poro_extractor.rb +13 -9
  110. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  111. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  112. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  113. data/lib/woods/extractors/route_extractor.rb +3 -1
  114. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  115. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  116. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  117. data/lib/woods/extractors/service_extractor.rb +3 -1
  118. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  119. data/lib/woods/extractors/shared_utility_methods.rb +48 -19
  120. data/lib/woods/extractors/source_nesting.rb +1 -1
  121. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  122. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  123. data/lib/woods/extractors/validator_extractor.rb +3 -1
  124. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  125. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  126. data/lib/woods/gem_mapper.rb +2 -0
  127. data/lib/woods/git_history.rb +116 -0
  128. data/lib/woods/graph_analyzer.rb +35 -6
  129. data/lib/woods/hooks/context_cli.rb +54 -0
  130. data/lib/woods/hooks/context_event.rb +88 -0
  131. data/lib/woods/hooks/context_hint.rb +73 -0
  132. data/lib/woods/hooks/context_impact.rb +77 -0
  133. data/lib/woods/hooks/context_output.rb +47 -0
  134. data/lib/woods/hooks/context_state.rb +102 -0
  135. data/lib/woods/hooks/refresh.rb +79 -0
  136. data/lib/woods/hooks/rule_projection.rb +78 -0
  137. data/lib/woods/input_rules.rb +19 -0
  138. data/lib/woods/mcp/bearer_auth.rb +22 -13
  139. data/lib/woods/mcp/bootstrapper.rb +79 -4
  140. data/lib/woods/mcp/config_resolver.rb +2 -1
  141. data/lib/woods/mcp/index_reader.rb +334 -162
  142. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  143. data/lib/woods/mcp/origin_guard.rb +17 -9
  144. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  145. data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
  146. data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
  147. data/lib/woods/mcp/search_results.rb +74 -0
  148. data/lib/woods/mcp/server.rb +178 -63
  149. data/lib/woods/mcp/tool_contract.rb +3 -1
  150. data/lib/woods/mcp/tool_response_renderer.rb +41 -0
  151. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  152. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  153. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  154. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  155. data/lib/woods/mcp/traversal_response.rb +22 -0
  156. data/lib/woods/notion/exporter.rb +56 -17
  157. data/lib/woods/obsidian/destination_plan.rb +98 -0
  158. data/lib/woods/obsidian/name_mapper.rb +19 -3
  159. data/lib/woods/obsidian/note_builder.rb +19 -10
  160. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  161. data/lib/woods/operator/pipeline_guard.rb +18 -13
  162. data/lib/woods/path_dispatcher.rb +13 -6
  163. data/lib/woods/payload_store.rb +27 -26
  164. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  165. data/lib/woods/published_index.rb +2 -2
  166. data/lib/woods/railtie.rb +3 -3
  167. data/lib/woods/railtie_support.rb +12 -12
  168. data/lib/woods/rake_helpers.rb +382 -0
  169. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  170. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  171. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  172. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  173. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  174. data/lib/woods/resilience/index_validator.rb +112 -23
  175. data/lib/woods/retrieval/context_assembler.rb +50 -15
  176. data/lib/woods/retrieval/lexical_assembler.rb +84 -0
  177. data/lib/woods/retrieval/lexical_index.rb +120 -0
  178. data/lib/woods/retrieval/ranker.rb +4 -2
  179. data/lib/woods/retrieval/scope.rb +108 -0
  180. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  181. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  182. data/lib/woods/retrieval/search_executor.rb +86 -27
  183. data/lib/woods/retrieval/source_evidence.rb +200 -0
  184. data/lib/woods/retriever.rb +98 -22
  185. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  186. data/lib/woods/session_tracer/file_store.rb +6 -1
  187. data/lib/woods/session_tracer/middleware.rb +10 -12
  188. data/lib/woods/session_tracer/redis_store.rb +22 -6
  189. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  190. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  191. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  192. data/lib/woods/source_inputs/consumer_errors.rb +31 -0
  193. data/lib/woods/source_inputs/handoff.rb +102 -0
  194. data/lib/woods/source_inputs/launcher.rb +157 -0
  195. data/lib/woods/source_inputs/manifest.rb +124 -0
  196. data/lib/woods/source_inputs/private_key.rb +55 -0
  197. data/lib/woods/source_inputs/scanner.rb +171 -0
  198. data/lib/woods/source_inputs/scopes.rb +71 -0
  199. data/lib/woods/source_inputs/session.rb +214 -0
  200. data/lib/woods/source_inputs/status.rb +84 -0
  201. data/lib/woods/source_inputs/verifier.rb +107 -0
  202. data/lib/woods/storage/metadata_store.rb +25 -25
  203. data/lib/woods/storage/pgvector.rb +35 -10
  204. data/lib/woods/storage/qdrant.rb +17 -7
  205. data/lib/woods/storage/vector_store.rb +18 -6
  206. data/lib/woods/tasks.rb +3 -2
  207. data/lib/woods/temporal/json_snapshot_store.rb +58 -9
  208. data/lib/woods/unblocked/exporter.rb +59 -70
  209. data/lib/woods/version.rb +1 -1
  210. data/lib/woods/watch/boot_snapshot.rb +52 -0
  211. data/lib/woods/watch/daemon.rb +154 -32
  212. data/lib/woods/watch/listen_watcher.rb +4 -0
  213. data/lib/woods/watch/polling_watcher.rb +5 -1
  214. data/lib/woods/watch/status.rb +20 -15
  215. data/lib/woods/watch/tree_scan.rb +21 -13
  216. data/lib/woods/watch/watcher.rb +4 -1
  217. data/lib/woods.rb +50 -11
  218. data/plugin/.claude-plugin/plugin.json +1 -1
  219. data/plugin/hooks/adapters/normalize.jq +15 -0
  220. data/plugin/hooks/adapters/normalize.rb +63 -0
  221. data/plugin/hooks/hooks.json +20 -0
  222. data/plugin/hooks/woods-context.sh +50 -0
  223. data/plugin/hooks/woods-input-rules.sh +159 -0
  224. data/plugin/hooks/woods-opencode.mjs +65 -0
  225. data/plugin/hooks/woods-post-edit.sh +2 -225
  226. data/plugin/hooks/woods-refresh.sh +260 -0
  227. data/plugin/hooks/woods-session-start.sh +47 -55
  228. data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
  229. data/plugin/skills/woods-diagnose/SKILL.md +319 -1
  230. data/plugin/skills/woods-investigate/SKILL.md +145 -0
  231. data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
  232. data/plugin/skills/woods-setup/SKILL.md +110 -6
  233. metadata +87 -5
data/docs/WATCH_DAEMON.md CHANGED
@@ -29,8 +29,10 @@ Ctrl-C to stop.
29
29
  | `WOODS_WATCH_DEBOUNCE` | `0.4` | Seconds of quiet before a batch is considered settled |
30
30
  | `WOODS_WATCH_FULL_THRESHOLD` | `50` | Actionable changed-file count above which a full extraction replaces incremental |
31
31
  | `WOODS_WATCH_POLL` | unset | `1` forces the polling backend, set this inside a container watching a bind mount |
32
+ | `WOODS_WATCH_POLL_INTERVAL` | `1.0` | Positive, finite seconds of sleep between polling scans; does not select the polling backend |
32
33
  | `WOODS_WATCH_IDLE_TIMEOUT` | unset | Seconds of quiet after which a dormant daemon exits |
33
34
  | `WOODS_WATCH_CATCH_UP` | `1` | `0` skips the startup reconciliation |
35
+ | `WOODS_WATCH_TRUST_FOREIGN_HOST` | unset | `1` lets a reader trust a fresh foreign-host heartbeat without checking its pid locally; see [cross-host liveness](#cross-host-liveness) |
34
36
 
35
37
  Run it under a supervisor. When boot-captured configuration changes the daemon
36
38
  exits `75` (`EX_TEMPFAIL`) on purpose, see [Restart triggers](#restart-triggers).
@@ -75,6 +77,13 @@ This is `rails/spring`'s contract, copied deliberately: Spring's staleness bugs
75
77
  came from under-scoping exactly this set, so the boundary here is drawn on the
76
78
  generous side.
77
79
 
80
+ A restart-trigger change found at startup is reconciled with one full extraction
81
+ when it is covered by the task's environment-boot snapshot. That advances the
82
+ generation through real extraction, so a supervisor restart does not repeatedly
83
+ exit `75` over the same files. Live restart triggers still stop the daemon,
84
+ including edits during startup extraction. Their paths survive shutdown even
85
+ when the preceding extraction has advanced the generation watermark.
86
+
78
87
  The same escalation happens when the app *can't* reload at all, a boot with
79
88
  `config.enable_reloading = false`. Extracting against constants that no longer
80
89
  match their source would be worse than saying so.
@@ -87,7 +96,7 @@ partial write:
87
96
 
88
97
  | Failure | What happens |
89
98
  |---|---|
90
- | Reload raises (`SyntaxError`, `NameError`) | Degraded status naming the reason; index intact at generation N; retried on the next event |
99
+ | Reload raises (`SyntaxError`, `NameError`) | Degraded status naming the reason; index intact at generation N; pending paths retried on the next file event or heartbeat |
91
100
  | Extraction raises | Degraded status; generation not advanced |
92
101
  | Payload directory can't be opened, over a payload-born index | Degraded status; generation not advanced. An incremental run only writes the units it touched, so there is no complete flat index it could fall back to publishing, see [Payload publishing](#payload-publishing) |
93
102
  | Index written but the generation bump failed | Degraded status; paths carried forward. The extractor deliberately does not fail an otherwise-good extraction over an unwritable marker, but the marker *is* what readers refresh on, so the daemon cross-checks that the number moved rather than reporting `running` over an index nothing can see |
@@ -110,17 +119,23 @@ at a known generation, reason attached), `stopped` (nothing is maintaining this
110
119
  index). A stale answer is only dangerous when nothing says so.
111
120
 
112
121
  The file is written world-readable (0644) by design: host-side hooks read it
113
- through a bind mount. Every other artifact Woods writes stays at 0600.
122
+ through a bind mount. Writes through `Woods::AtomicFile` default to owner-only
123
+ 0600 unless the caller supplies another mode. This is not a guarantee for every
124
+ Woods artifact: the SQLite metadata store does not enforce 0600, and a newly
125
+ created database uses 0644 under umask 022. Restrict access to the output
126
+ directory according to the source and metadata it contains.
114
127
 
115
128
  Note that `SyntaxError` is a `ScriptError`, not a `StandardError`. Rescuing
116
129
  only the latter would let a half-typed file kill the daemon.
117
130
 
118
131
  A cycle that fails to land its work never loses its paths. Lock contention, a
119
132
  failed reload, and a raising extraction all carry the batch into `@pending`, and
120
- the next cycle folds it back in, the files really did change, and no later
121
- event will mention them again. The retry is not a tight loop: a degraded cycle
122
- ends the drain and waits for the next event, because the cause needs an edit to
123
- clear.
133
+ the next cycle folds it back in even if no new event mentions those files.
134
+ A degraded cycle ends the current drain to avoid a tight retry loop. Pending
135
+ paths are retried on the next file event or [heartbeat](#the-heartbeat), so a
136
+ finished contending writer does not require another edit to trigger recovery.
137
+ Heartbeat retries use a separate worker so status updates and lock refresh
138
+ continue while extraction runs.
124
139
 
125
140
  ### The heartbeat
126
141
 
@@ -151,6 +166,24 @@ it starts: edits and pulled commits that landed while nothing was watching are
151
166
  invisible to it forever. That matters because callers stand down when a daemon
152
167
  is alive, so *alive has to mean covered*.
153
168
 
169
+ The standalone `woods:watch` task snapshots reload/restart inputs before invoking
170
+ Rails' `environment` task. Inputs unchanged across that boundary, including
171
+ carried paths that remain deleted, may be reconciled by a full extraction.
172
+ Unreleased after `2.0.0.beta3`: registered restart inputs deleted while the
173
+ daemon was stopped also trigger a full extraction after a fresh environment
174
+ boot. Nominal framework paths still use the bounded deletion sweep.
175
+ Changes during environment initialization still require restart. Lock contention,
176
+ extraction failure, and publication failure retain the full-reconciliation
177
+ obligation for retry; a successful publish clears it.
178
+
179
+ This boundary covers **environment initialization**. Bundler and
180
+ `config/application.rb` can run before the task begins; the snapshot does not
181
+ prove that edits during those earlier stages were incorporated. Start the task
182
+ against a settled boot configuration. If Rails is already initialized or the
183
+ `environment` task was already invoked, the daemon keeps conservative restart
184
+ handling. Use `bundle exec rake woods:watch` as a separate process, rather than
185
+ `bundle exec rake environment woods:watch`.
186
+
154
187
  So `run` reconciles before it waits. The watermark is `generation.json`'s mtime, written last on every successful run, so it means "when this index was last
155
188
  known good", and everything modified since is uncovered, whoever changed it.
156
189
  With no generation file there is no index, every file is uncovered, and the
@@ -161,7 +194,12 @@ external cleanup targeting the large directories), and readers deliberately
161
194
  degrade a dangling pointer to the index root, so trusting the mtime there would
162
195
  report "current at startup" over a directory holding nothing.
163
196
 
164
- **The watcher thread starts before this reconciliation runs, not after.** A
197
+ **The built-in watcher establishes detection before reconciliation runs.**
198
+ Polling signals readiness after its baseline scan; native watching signals after
199
+ listener startup, including a fallback to polling. Startup waits up to 30 seconds
200
+ for readiness and reports an error if detection cannot start. Callbacks enqueue
201
+ live events immediately, while extraction waits until startup obligations are
202
+ established. A
165
203
  file saved while catch-up's own extraction is still in flight (which can take
166
204
  minutes on a storm-triggered full run) used to be lost twice: no watcher
167
205
  existed yet to see it, and the polling watcher takes its baseline snapshot
@@ -173,8 +211,9 @@ already tolerate the duplicate paths this produces against whatever catch-up
173
211
  finds on its own via the tree scan.
174
212
 
175
213
  Deletions need one extra step, because a deleted file leaves no mtime to scan:
176
- if any path the index attributes a unit to is gone from disk, the daemon runs
177
- one cycle with an *empty* change set, which reaches the ghost units through the
214
+ registered restart inputs follow the full-reconciliation rule above. For other
215
+ registered paths gone from disk, a deletion-only startup runs one cycle with an
216
+ *empty* change set, which reaches the ghost units through the
178
217
  extractor's bounded deletion sweep. Deliberately empty, naming the paths would
179
218
  make the deletions authoritative for every unit type, and some registered paths
180
219
  are nominal (on Rails < 7.1, `ActiveRecord::SchemaMigration` registers a
@@ -185,7 +224,7 @@ supplies the trigger.
185
224
  This is what makes the documented hook pattern safe:
186
225
 
187
226
  ```bash
188
- bundle exec rake woods:watch_status || start_the_daemon
227
+ bundle exec rake woods:watch_status || start_the_daemon # same host; see cross-host liveness below
189
228
  bundle exec rake woods:incremental # stands down, the daemon has these
190
229
  ```
191
230
 
@@ -215,9 +254,19 @@ polling rather than trust a watcher that may sit silent while files change
215
254
  under it:
216
255
 
217
256
  ```bash
218
- WOODS_WATCH_POLL=1 bundle exec rake woods:watch
257
+ WOODS_WATCH_POLL=1 WOODS_WATCH_POLL_INTERVAL=2.5 bundle exec rake woods:watch
219
258
  ```
220
259
 
260
+ ### Polling cost
261
+
262
+ For a slow bind mount, increase `WOODS_WATCH_POLL_INTERVAL` to reduce scan
263
+ frequency. The default is 1.0 second; the example above uses 2.5 seconds.
264
+ Each cycle also includes the scan's duration. Longer intervals can delay
265
+ change detection and polling shutdown. The value also applies when a native
266
+ watcher fails and falls back to polling; it has no effect while native watching
267
+ is active. Blank, malformed, nonfinite, zero and negative values are rejected
268
+ before the daemon starts.
269
+
221
270
  Selection is also self-correcting at runtime. If `listen` cannot start at all, inotify watch exhaustion (`ENOSPC`) is the usual reason on a large tree, the
222
271
  daemon logs it and falls back to polling rather than exiting, because a daemon
223
272
  costing some CPU beats one that never fires. Failures *after* startup are not
@@ -234,6 +283,14 @@ Ignored by default: `.git`, `node_modules`, `tmp`, `log`, `coverage`,
234
283
  `vendor/bundle`, `public/assets`, `public/packs`, `storage`. That ignore list is
235
284
  what keeps a polling scan bounded.
236
285
 
286
+ Polling and startup catch-up preserve each logical path when multiple directory
287
+ symlinks point to the same source tree. For example, `a_shared/user.rb` and
288
+ `app/models/user.rb` both remain visible; an earlier alias must not hide the path
289
+ that extraction recognizes. Cycles back to a directory already on the current
290
+ traversal branch are pruned, while independent sibling aliases remain visible.
291
+ This can increase scan work for deliberately repeated aliases; avoid unnecessary
292
+ aliases in large watched trees. Ignored logical paths are still pruned.
293
+
237
294
  ## Placement
238
295
 
239
296
  The spike asked for three placements to be compared and one chosen. Every
@@ -418,13 +475,11 @@ party) behaves exactly as it always did.
418
475
  was told the index matched HEAD while every answer described the tree before
419
476
  those edits.
420
477
 
421
- The fingerprint (a digest of `git status --porcelain`) is *as of the call*.
422
- Nothing records the digest the index was built at, so it cannot tell you "this
423
- is the same dirty state the index describes", it gives a stable identity for
424
- the current dirty state, so two of your own calls can be compared to detect the
425
- tree moving underneath you. Pair it with `generation` to distinguish "tree
426
- changed and the index followed" from "tree changed and the index has not caught
427
- up".
478
+ The fingerprint hashes the current `git status --porcelain` path/status list.
479
+ Repeated edits to the same already-dirty file can leave it identical. It is not
480
+ content identity. Use `index.source_freshness` for generation-bound content
481
+ verification; see [source freshness](SOURCE_FRESHNESS.md) for `current`, `drifted`,
482
+ `unknown`, scan budgets, fresh-process capture and partial-runtime limitations.
428
483
 
429
484
  ### Multi-file read consistency
430
485
 
@@ -534,7 +589,7 @@ and a hook-triggered `woods:incremental`. They share the existing file-based
534
589
  A hook can check cheaply:
535
590
 
536
591
  ```bash
537
- bundle exec rake woods:watch_status || start_the_daemon # exit 0 = alive
592
+ bundle exec rake woods:watch_status || start_the_daemon # same host, exit 0 = alive
538
593
  ```
539
594
 
540
595
  The check does not boot Rails. Without `WOODS_OUTPUT`, it resolves
@@ -543,63 +598,145 @@ launcher's current directory, so `rake -f /app/Rakefile woods:watch_status`
543
598
  and worktree-manager invocations inspect the same per-app status. Set
544
599
  `WOODS_OUTPUT` when the daemon uses a non-default index directory.
545
600
 
546
- Liveness needs three things to agree, each ruling out a different way the
547
- status file lies: a state a live daemon writes, a pid that still exists (a
548
- `kill -9` leaves the file behind), and a recent timestamp (a machine that lost
549
- power leaves a `running` record whose pid some unrelated process now owns).
601
+ ### Cross-host liveness
550
602
 
551
- One known limit: the pid check sees only the caller's own pid namespace. In the
552
- Docker layout, daemon in the container, output volume-mounted to the host, a
553
- host-side `watch_status` tests a host pid that has nothing to do with the
554
- containerized daemon, so it can misread liveness in either direction for up to
555
- `STALE_AFTER` (the timestamp check still bounds it, and the heartbeat keeps a
556
- live daemon inside that bound). Run `watch_status` on the same side as the
557
- daemon; a cross-namespace liveness protocol isn't worth its complexity here.
603
+ By default, a reader trusts only a same-host record: a `running` or `degraded`
604
+ state, a positive pid that still exists, and a recent ISO8601 timestamp. Foreign
605
+ hostnames are rejected because a container pid cannot be checked on the host.
606
+
607
+ For a daemon and reader sharing the same index through a bind mount, opt in in
608
+ each reader's environment:
609
+
610
+ ```bash
611
+ export WOODS_WATCH_TRUST_FOREIGN_HOST=1
612
+ bundle exec rake woods:watch_status || start_the_daemon
613
+ ```
614
+
615
+ Set the variable inside one-off containers running `woods:incremental`, and in
616
+ the host MCP process when it reports `woods_status`. Docker does not forward a
617
+ host environment variable automatically: pass `-e WOODS_WATCH_TRUST_FOREIGN_HOST=1`
618
+ to `docker compose run` or `docker compose exec`, or configure that service's
619
+ environment. Use the same shared index (`WOODS_OUTPUT` when needed) in each process.
620
+
621
+ Opted-in readers accept foreign `running` and `degraded` records on heartbeat
622
+ freshness, without any local pid lookup. Heartbeats run every five minutes; a
623
+ crashed foreign daemon can still be believed for up to 15 minutes after its last
624
+ heartbeat. Missing or malformed timestamps and timestamps more than 30 seconds
625
+ in the future are rejected. Keep the participating clocks synchronized.
626
+
627
+ `degraded` means alive but unable to update: `watch_status` exits 0, incremental
628
+ still attempts extraction, and clean refuses. `WOODS_IGNORE_WATCH=1` still
629
+ overrides writer stand-down and clean protection. It does not alter the status
630
+ report. Direct Ruby callers can override the environment with
631
+ `Status#alive?(trust_foreign_host: true)` or `false`.
632
+
633
+ This is liveness evidence for an established daemon, not a cross-container
634
+ startup lease. Simultaneous starts in foreign namespaces still need one
635
+ supervisor to coordinate ownership. Hostnames are also imperfect identity:
636
+ custom or reused identical container hostnames retain the local-pid limitation.
558
637
 
559
638
  ### Hooks for agent sessions
560
639
 
640
+ For client registration and the supported Claude/OpenCode event shapes, see
641
+ [edit client adapters](CLIENT_HOOKS.md). Both use the shared queue below.
642
+
561
643
  The daemon covers a human's editor session. A `claude -p` run in a worktree
562
- with no daemon needs a different trigger, so the Woods plugin ships two
644
+ with no daemon needs a different trigger, so the Woods plugin ships two freshness
563
645
  hooks (`plugin/hooks/hooks.json`), both shipped disabled:
564
646
 
565
647
  | Hook | When | What it does |
566
648
  |---|---|---|
567
- | `PostToolUse` (`Edit`, `Write`, `MultiEdit`), async | An edit under `app/models`, `config/routes*`, `db/migrate`, `db/*_migrate`, `db/schema.rb`, `db/structure.sql`, or any `package.yml` / `packwerk.yml` | Appends the path to `hook-pending.txt` under a lock, then runs `CHANGED_FILES=<paths> woods:incremental` for whatever is pending, output to `hook.log` |
568
- | `SessionStart` (`startup`, `resume`) | Session begins | Prints a warning when `generation.json`'s `updated_at` predates `git log -1` |
569
-
570
- Both read `cwd` from the hook payload, not `CLAUDE_PROJECT_DIR`, which stays
571
- at the launch root inside a worktree. Both do nothing until
572
- `tmp/woods/generation.json` exists, and neither runs at all until
573
- `WOODS_HOOKS_ENABLED=1` is set; `WOODS_HOOKS_DISABLED=1` turns them back off
574
- without touching that setting. `woods:incremental` still stands down under a
575
- `:running` daemon, so a hook and a daemon on the same worktree never
576
- contend. `WOODS_HOOK_RAKE` sets the command prefix (Docker:
577
- `docker compose exec -T app bundle exec rake`); `WOODS_OUTPUT` points the
578
- hooks at a non-default index directory, the same variable
579
- `woods:incremental`/`woods:watch_status` already read.
580
-
581
- A hook invocation that finds another one already draining the pending file
582
- does not wait for it: it appends its own path and returns, and the
583
- in-progress drainer picks that path up on its next pass, looping until a
584
- drain comes back empty. The one gap this leaves is an append that lands
585
- between the drainer's last (empty) drain and its releasing the lock: that
586
- edit is delayed to the next graph-changing edit rather than lost outright,
587
- and the `SessionStart` warning is the backstop for it.
588
-
589
- On a host without `flock`, the mkdir-based fallback lock has no kernel-enforced
590
- release, so a hook killed mid-drain would otherwise leave a lock directory
591
- behind forever; each lock directory is reclaimed once its mtime is older than
592
- `WOODS_HOOK_LOCK_STALE_SECONDS` (default 1800), while a fresh one is still
593
- respected as busy.
594
- The age check needs `stat`; on a host with neither `flock` nor `stat`, a crashed
595
- pending-lock holder can still make the next hook wait until the hook timeout.
596
-
597
- The `SessionStart` warning compares two commit-adjacent timestamps only:
598
- the generation's `updated_at` against the last commit's time. It says
599
- nothing about uncommitted changes in the working tree, and a checkout
600
- sitting on an older commit than the one that produced the generation can
601
- still read as fresh under this check. Treat a quiet session start as "not
602
- behind the last commit," not as a general freshness guarantee.
649
+ | `PostToolUse` (`Edit`, `Write`, `MultiEdit`), async | A supported extraction or boot input changes | Queues an immutable JSON event, then calls `woods:hook_refresh[<encoded batch>]`; output goes to `hook.log` |
650
+ | `SessionStart` (`startup`, `resume`) | Session begins | Checks source content through `woods:source_status`; warns on drift or unknown evidence |
651
+
652
+ Both read `cwd` from the hook payload, so a linked worktree uses its own index.
653
+ Both require an existing `generation.json` and `WOODS_HOOKS_ENABLED=1`;
654
+ `WOODS_HOOKS_DISABLED=1` overrides enablement. The broader refresh task is
655
+ **unreleased after Woods 2.0.0.beta2**. Check the installed gem's task list
656
+ (`bundle exec rake -T woods:hook_refresh`, through the application container
657
+ when appropriate) before enabling this plugin version. An older gem's unknown
658
+ task error leaves queued events in place; installing the plugin does not upgrade
659
+ the gem.
660
+
661
+ The portable path predicate is generated from `PathDispatcher` and
662
+ `ReloadPolicy` with `bundle exec ruby -Ilib script/generate-hook-rules`.
663
+ A contract test rejects stale generated rules. It covers services, controllers,
664
+ jobs, concerns, views, locales, supported test/lib files, routes, package
665
+ boundaries, and the remaining standard extractor triggers. Unrelated documents
666
+ stay quiet. Normal edits use fresh-process incremental extraction; changes to
667
+ initializers, boot configuration, dependencies, schema, or other restart inputs
668
+ use fresh-process full extraction. The transport also preserves explicit
669
+ `add`, `update`, `delete`, and `move` operations; a relevant deletion/move selects
670
+ full extraction to remove runtime classes absent from the next boot. The Claude
671
+ adapter receives one `tool_input.file_path`. The OpenCode adapter supplies every
672
+ verified patch metadata path, including both rename sides. Neither infers paths
673
+ from shell commands or parses patch text. Custom runtime roots outside the
674
+ standard dispatcher rules require an explicit refresh; the portable predicate
675
+ cannot discover application configuration without booting it.
676
+
677
+ `WOODS_HOOK_RAKE` sets the command prefix (Docker:
678
+ `docker compose exec -T app bundle exec rake`). The encoded JSON task argument
679
+ carries paths and the output setting across the container boundary, without
680
+ assuming Docker forwards host environment variables or can read a host queue
681
+ filename. The host needs Bash 3.2 or later, standard Unix tools, and either `jq`
682
+ or Ruby; it does not need the application bundle. Prefix words are split without
683
+ shell evaluation: use an executable wrapper for quoted arguments or extra
684
+ container environment settings. `WOODS_OUTPUT` overrides `tmp/woods`, relative
685
+ to each process's application root or as an explicitly supplied absolute path;
686
+ absolute paths must be valid on both sides of a container bind mount.
687
+
688
+ Each event remains under `<output>/hook-pending/` until the task succeeds.
689
+ Successful no-op consumption is acknowledged too. Contending invocations enqueue
690
+ and return while the owner drains bounded batches (up to 16 queue files /
691
+ 1,000 paths / 48 KiB of JSON, without splitting a multi-file event). Commas,
692
+ spaces, and newlines are preserved. An empty drain releases the lock before
693
+ checking again, so a final arriving event can acquire ownership.
694
+ A failed command, killed worker, incompatible gem, or publication failure retains
695
+ its batch for retry: delivery is **at least once**, so crash recovery can repeat
696
+ already completed work. Pending events in the previous `hook-pending.txt` format
697
+ are imported on the next relevant edit. Event filenames are private hook state;
698
+ do not modify them while a worker is running.
699
+
700
+ An active daemon produces exit **75** before Rails boots. This is a deferral,
701
+ not acknowledgement: the hook cannot prove which queued events the daemon has
702
+ consumed. It retains the queue and writes a diagnostic. It does not start, stop,
703
+ or restart the daemon. After resolving the cause, the next relevant edit retries
704
+ the queue. To retry immediately, invoke `woods-post-edit.sh` with the original
705
+ JSON event on stdin and the same opt-in/output/prefix settings; with a running
706
+ daemon, stop it first or explicitly configure `WOODS_IGNORE_WATCH=1` in the
707
+ application command's environment. A quiet `SessionStart` does not acknowledge
708
+ the queue. Prefer a resident watcher for sustained edits; enabling both does not
709
+ make refresh faster and can accumulate deferred events.
710
+
711
+ After the complete event input has been read, validated, and queued, the refresh
712
+ hook starts its `WOODS_HOOK_TIMEOUT_SECONDS` deadline (default 600, integer range
713
+ 1–3600), including subsequent batches. The producer must close stdin: the 1 MiB
714
+ input limit bounds bytes, not time waiting for EOF. The deadline terminates the local
715
+ command process group and retains work on timeout. For a Docker exec prefix,
716
+ local process termination cannot guarantee cancellation inside the container;
717
+ check the application process and extraction lock before retrying a timed-out
718
+ container run. Async client hook timeouts are not a reliable worker deadline.
719
+ With `flock`, kernel locks release after process exit. The mkdir fallback records
720
+ an owner PID and reclaims dead owners; it never steals a live owner's lock based
721
+ only on age. Only the invocation that removes the recorded dead-owner marker
722
+ may replace its lock directory; competing reclaimers leave their events queued
723
+ for the winning owner. Legacy empty lock directories use `stat` and
724
+ `WOODS_HOOK_LOCK_STALE_SECONDS` (default 1800) for conservative recovery.
725
+ A reused PID can delay recovery until that process exits; inspect the recorded
726
+ owner before manually removing a lock. Hooks sharing this filesystem must run in
727
+ the same host PID namespace; run the actual extraction through the container
728
+ prefix instead of running competing host/container hook workers.
729
+
730
+ Broader coverage increases the number of Rails boots. A view or locale edit now
731
+ costs a fresh incremental run, while a boot/config edit costs a full run. There
732
+ is no provider or embedding call added by this hook. Opt in for occasional agent
733
+ edits; use `woods:watch` for repeated work, and keep full extraction for large
734
+ change sets as described above.
735
+
736
+ The `SessionStart` hook uses the shared quick source verifier through
737
+ `WOODS_HOOK_RAKE`. It has a ten-second command deadline, including startup;
738
+ failed commands and old gems lacking `woods:source_status` report unknown.
739
+ It does not initialize Rails or start a provider. See [source freshness](SOURCE_FRESHNESS.md#containers-and-hooks).
603
740
 
604
741
  ### Reader multiplicity is free
605
742
 
@@ -663,5 +800,78 @@ result = daemon.process(changed_paths)
663
800
  # => { action: :incremental, state: :running, generation: 42, count: 1, duration_ms: 61 }
664
801
  ```
665
802
 
803
+ For an embedded `#run`, pass `boot_snapshot: Woods::Watch::BootSnapshot.new(root: …)`
804
+ with the snapshot captured **before** environment initialization if the host can
805
+ establish that boundary. Without it, startup restart inputs remain restart
806
+ requests. Direct `#process` calls always preserve conservative restart handling.
807
+ Injected watchers retain their existing `start`/`stop` interface; those with
808
+ asynchronous startup can implement `ready_callback=` and call it after detection
809
+ is established to participate in the readiness handshake.
810
+
666
811
  `#process` is one whole cycle and is the supported embedding point. `#run` only
667
812
  supplies batches to it.
813
+
814
+ ### Optional bounded context hints
815
+
816
+ Context hints are a separate Claude Code opt-in, **unreleased after Woods
817
+ 2.0.0.beta2**. Verify `bundle exec woods-hook-context --help` in the installed
818
+ application bundle before enabling `WOODS_HOOK_CONTEXT_ENABLED=1`. The plugin
819
+ version alone does not establish gem support. `WOODS_HOOKS_DISABLED=1` disables
820
+ both context and refresh; `WOODS_HOOKS_ENABLED` controls only the existing
821
+ freshness/refresh hooks. Either feature can work without the other.
822
+
823
+ Separate synchronous SessionStart and PostToolUse entries emit Claude's
824
+ `hookSpecificOutput.additionalContext`. SessionStart gives a short served-index
825
+ orientation. After a relevant native Edit/Write/MultiEdit, the hint identifies
826
+ candidates from one retained published generation. Direct candidates and
827
+ transitive candidates are distinguished; test mappings are suggestions, never
828
+ proof of coverage. Post-edit hints always say **pre-refresh snapshot** because
829
+ an edit can precede publication. Source freshness is checked against that same
830
+ payload; unknown/drifted evidence remains explicit. An unresolved or ambiguous
831
+ edited identity directs the agent to manual search and typed lookup. No match
832
+ within the bounded snapshot establishes neither absence nor no impact.
833
+
834
+ Limits are fixed: depth 2, at most 10 visited nodes including the root, 100
835
+ examined edges, and 2 KiB for the **entire JSON output**, preserving whole rows.
836
+ An index is refused above 16 MiB per required artifact or 50,000 combined graph
837
+ nodes/variants. These preparation checks, JSON parsing, cache construction,
838
+ source verification, path/content hashing, formatting and suppression state all
839
+ run within the hook's private process-group deadline: the worker is killed at
840
+ 850 ms, leaving dispatch/cleanup headroom within a one-second work budget.
841
+ The helper also has a 650 ms inner deadline. OS scheduling can delay observation
842
+ of a deadline. Cold bundle/container startup can therefore produce no hint;
843
+ the deadline is not extended. Oversized evidence is marked truncated; missing,
844
+ corrupt, unsupported or timed-out input produces a short unknown notice or
845
+ silence. Silence is never a complete/no-impact claim. The hint boots no Rails
846
+ application and calls no provider.
847
+
848
+ The synchronous opt-in can add up to this budget to a supported tool call. It
849
+ makes context available to Claude's next model request; it does not rely on the
850
+ later-turn delivery of the independent asynchronous refresh worker. A reminder
851
+ need not appear as a visible transcript entry. See the
852
+ [Claude context-output contract](https://code.claude.com/docs/en/hooks#add-context-for-claude).
853
+
854
+ The default command is `bundle exec woods-hook-context`. Set
855
+ `WOODS_HOOK_CONTEXT_COMMAND` to an executable wrapper or argv prefix for a
856
+ container-only bundle. Prefix words are split without shell evaluation; a
857
+ wrapper handles quoted arguments. Explicit `WOODS_HOOK_CONTEXT_ROOT` maps the
858
+ hook payload's original cwd and contained edit path onto a runtime-visible
859
+ application root. For example, a container prefix can include
860
+ `docker compose exec -T -e WOODS_HOOK_CONTEXT_ENABLED=1 -e WOODS_HOOK_CONTEXT_ROOT=/app app bundle exec woods-hook-context`.
861
+ Forward a custom `WOODS_OUTPUT` explicitly too. Running Claude inside the
862
+ application container avoids external container startup and path mapping.
863
+
864
+ Repeat suppression uses session/worktree, served generation/token, changed-file
865
+ content identity and normalized hint content. Repeated identical evidence stays
866
+ quiet; later same-file edits and generation changes can reappear. Missing session
867
+ or bounded content identity disables suppression. Private `hook-context-state.json`
868
+ retains at most 32 sessions and 32 emitted identities per session under a separate
869
+ nonblocking lock. It records **emitted**, not confirmed delivered, hints.
870
+ Contending or unavailable state may skip optional context. Its bounded atomic
871
+ state update never reads, acknowledges, or clears `hook-pending`, nor acquires
872
+ refresh/watch locks. Disable context to roll back without changing refresh.
873
+
874
+ Only the native Claude context entries are supported here; the OpenCode adapter
875
+ continues to provide refresh events. No prompt-triggered retrieval is injected.
876
+
877
+ See the [matched public Rails task comparison](EVALUATION.md#matched-optional-context-hook-tasks-406) for delivered hints, task outcomes, measured overhead and observed limitations.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/woods/agent_configuration/cli'
5
+
6
+ exit Woods::AgentConfiguration::CLI.new.run(ARGV)
data/exe/woods-extract ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'woods/source_inputs/launcher'
5
+ exit Woods::SourceInputs::Launcher.run(ARGV)
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'woods/hooks/context_cli'
5
+
6
+ exit Woods::Hooks::ContextCLI.run(ARGV.first)
data/exe/woods-mcp-start CHANGED
@@ -7,7 +7,7 @@
7
7
  # same as when launched directly. RubyGems loads gem executables as Ruby, so
8
8
  # this wrapper must remain a Ruby program when packaged.
9
9
 
10
- index_dir = ARGV[0] || ENV.fetch('WOODS_DIR', nil)
10
+ index_dir = ARGV[0] || ENV.fetch('WOODS_DIR', nil) || ENV.fetch('WOODS_OUTPUT', nil)
11
11
 
12
12
  if index_dir.nil? || index_dir.empty?
13
13
  warn 'Error: No index directory specified.'
@@ -15,9 +15,13 @@ if index_dir.nil? || index_dir.empty?
15
15
  exit 1
16
16
  end
17
17
 
18
+ index_dir = File.expand_path(index_dir)
19
+ path_remedy = 'Point at the existing index with an explicit path, WOODS_DIR, or WOODS_OUTPUT. ' \
20
+ 'If no index exists, run `bundle exec rake woods:extract` in your Rails app.'
21
+
18
22
  unless File.directory?(index_dir)
19
23
  warn "Error: Index directory does not exist: #{index_dir}"
20
- warn 'Run extraction first: bundle exec rake woods:extract'
24
+ warn path_remedy
21
25
  exit 1
22
26
  end
23
27
 
@@ -27,7 +31,7 @@ end
27
31
  # whole library just to check one file is wasted work on every boot. A
28
32
  # payload-born index has no manifest.json at the root — it lives under the
29
33
  # directory generation.json's `payload` pointer names — so the pointer is
30
- # followed here too, with the same escape guard, before concluding the
34
+ # followed here too, with the same realpath containment check, before concluding the
31
35
  # directory holds no index. woods-mcp re-checks this properly through
32
36
  # Bootstrapper regardless; this is just an early, friendlier exit.
33
37
  def manifest_present?(index_dir)
@@ -39,20 +43,21 @@ def manifest_present?(index_dir)
39
43
  require 'json'
40
44
  require_relative '../lib/woods/atomic_file'
41
45
  payload_name = JSON.parse(Woods::AtomicFile.read(generation_path))['payload']
42
- return false if payload_name.nil? || payload_name.empty?
46
+ return false unless payload_name.is_a?(String) && !payload_name.empty?
43
47
 
44
- root = File.expand_path(index_dir)
45
- candidate = File.expand_path(File.join(root, payload_name))
48
+ root = File.realpath(index_dir)
49
+ candidate = File.realpath(payload_name, root)
46
50
  return false unless candidate.start_with?("#{root}#{File::SEPARATOR}")
47
51
 
48
52
  File.file?(File.join(candidate, 'manifest.json'))
49
- rescue JSON::ParserError, SystemCallError
53
+ rescue JSON::ParserError, SystemCallError, TypeError, NoMethodError
50
54
  false
51
55
  end
52
56
 
53
57
  unless manifest_present?(index_dir)
54
- warn "Error: No manifest.json in: #{index_dir}"
55
- warn 'Run extraction first: bundle exec rake woods:extract'
58
+ warn "Error: Could not resolve a published Woods index in: #{index_dir}"
59
+ warn 'Expected generation.json pointing to a payload manifest.json, or a legacy flat manifest.json.'
60
+ warn path_remedy
56
61
  exit 1
57
62
  end
58
63
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'rails/generators/active_record'
5
+ require 'woods/storage/pgvector'
5
6
 
6
7
  module Woods
7
8
  module Generators
@@ -15,7 +16,7 @@ module Woods
15
16
  #
16
17
  # Usage:
17
18
  # rails generate woods:pgvector
18
- # rails generate woods:pgvector --dimensions 3072
19
+ # rails generate woods:pgvector --dimensions 768
19
20
  #
20
21
  class PgvectorGenerator < Rails::Generators::Base
21
22
  include ActiveRecord::Generators::Migration
@@ -25,11 +26,16 @@ module Woods
25
26
  desc 'Creates the woods_vectors table (pgvector column + HNSW index) used by the Woods vector store'
26
27
 
27
28
  class_option :dimensions, type: :numeric, default: 1536,
28
- desc: 'Vector dimensions (1536 for text-embedding-3-small, 3072 for large)'
29
+ desc: 'Vector dimensions (1-2000; default 1536 for text-embedding-3-small)'
29
30
 
30
31
  # @return [void]
31
32
  def create_migration_file
32
33
  @dimensions = options[:dimensions]
34
+ maximum = Woods::Storage::VectorStore::Pgvector::MAX_HNSW_DIMENSIONS
35
+ unless @dimensions.is_a?(Integer) && @dimensions.between?(1, maximum)
36
+ raise ArgumentError, "dimensions must be a positive Integer no greater than #{maximum} for pgvector HNSW"
37
+ end
38
+
33
39
  migration_template(
34
40
  'add_pgvector_to_woods.rb.erb',
35
41
  'db/migrate/add_pgvector_to_woods.rb'
@@ -24,9 +24,6 @@ Woods.configure do |config|
24
24
  # Maximum tokens returned in a retrieval context window.
25
25
  # config.max_context_tokens = 8_000
26
26
 
27
- # Minimum vector similarity score (0.0–1.0) for retrieval results.
28
- # config.similarity_threshold = 0.7
29
-
30
27
  # Output format for retrieval: :claude, :markdown, :plain, :json
31
28
  # config.context_format = :markdown
32
29
 
@@ -124,6 +121,7 @@ Woods.configure do |config|
124
121
  # HTTP calls, callbacks, threads, or other connections/shards.
125
122
 
126
123
  # config.console_mcp_enabled = false
124
+ # config.console_mcp_http_enabled = true # set false for stdio-only Console use
127
125
  # config.console_mcp_path = '/mcp/console'
128
126
 
129
127
  # Console HTTP requires a strong bearer token. Its Origin/Host guard is