woods 2.0.0.beta1 → 2.0.0.beta3

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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +400 -1
  3. data/CONTRIBUTING.md +224 -9
  4. data/README.md +7 -3
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +83 -4
  7. data/docs/AGENT_SETUP.md +82 -1
  8. data/docs/BACKEND_MATRIX.md +20 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +233 -13
  11. data/docs/CONSOLE_MCP_SETUP.md +35 -5
  12. data/docs/DOCKER_SETUP.md +21 -2
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +36 -5
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +158 -2
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +15 -7
  20. data/docs/MCP_SERVERS.md +221 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +33 -18
  22. data/docs/NOTION_INTEGRATION.md +13 -0
  23. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  24. data/docs/PUBLISHED_INDEX.md +71 -0
  25. data/docs/README.md +7 -0
  26. data/docs/RETRIEVAL_GUIDE.md +253 -11
  27. data/docs/RUNTIME_TRACING.md +71 -0
  28. data/docs/SOURCE_FRESHNESS.md +143 -0
  29. data/docs/TROUBLESHOOTING.md +117 -5
  30. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  31. data/docs/UPGRADING_TO_2.md +44 -22
  32. data/docs/WATCH_DAEMON.md +259 -59
  33. data/exe/woods-agent-config +6 -0
  34. data/exe/woods-extract +5 -0
  35. data/exe/woods-hook-context +6 -0
  36. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  37. data/lib/tasks/woods.rake +47 -397
  38. data/lib/woods/agent_configuration/applier.rb +133 -0
  39. data/lib/woods/agent_configuration/cli.rb +101 -0
  40. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  41. data/lib/woods/agent_configuration/document.rb +105 -0
  42. data/lib/woods/agent_configuration/error.rb +7 -0
  43. data/lib/woods/agent_configuration/launcher.rb +75 -0
  44. data/lib/woods/agent_configuration/layout.rb +59 -0
  45. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  46. data/lib/woods/agent_configuration/plan.rb +98 -0
  47. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  48. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  49. data/lib/woods/agent_configuration/planner.rb +63 -0
  50. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  51. data/lib/woods/agent_configuration/preflight.rb +100 -0
  52. data/lib/woods/agent_configuration/recovery.rb +49 -0
  53. data/lib/woods/ast/node.rb +2 -0
  54. data/lib/woods/ast/parser.rb +38 -5
  55. data/lib/woods/atomic_file.rb +133 -3
  56. data/lib/woods/builder.rb +21 -5
  57. data/lib/woods/cache/cache_middleware.rb +28 -7
  58. data/lib/woods/cache/cache_store.rb +4 -5
  59. data/lib/woods/change_set.rb +5 -4
  60. data/lib/woods/console/credential_index.rb +20 -2
  61. data/lib/woods/console/credential_scanner.rb +14 -14
  62. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  63. data/lib/woods/console/embedded_executor.rb +1 -1
  64. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  65. data/lib/woods/console/rack_middleware.rb +22 -13
  66. data/lib/woods/console/server.rb +18 -16
  67. data/lib/woods/dependency_graph.rb +65 -13
  68. data/lib/woods/embedding/corpus.rb +94 -0
  69. data/lib/woods/embedding/indexer.rb +90 -46
  70. data/lib/woods/embedding/openai.rb +17 -6
  71. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  72. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  73. data/lib/woods/export/typed_reader.rb +56 -0
  74. data/lib/woods/extractor.rb +557 -228
  75. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  76. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  77. data/lib/woods/extractors/caching_extractor.rb +3 -1
  78. data/lib/woods/extractors/concern_extractor.rb +64 -6
  79. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  80. data/lib/woods/extractors/controller_extractor.rb +13 -4
  81. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  82. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  83. data/lib/woods/extractors/engine_extractor.rb +3 -1
  84. data/lib/woods/extractors/event_extractor.rb +4 -2
  85. data/lib/woods/extractors/factory_extractor.rb +3 -1
  86. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  87. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  88. data/lib/woods/extractors/job_extractor.rb +6 -19
  89. data/lib/woods/extractors/lib_extractor.rb +3 -1
  90. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  91. data/lib/woods/extractors/manager_extractor.rb +3 -1
  92. data/lib/woods/extractors/method_parameters.rb +53 -0
  93. data/lib/woods/extractors/middleware_argument.rb +65 -0
  94. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  95. data/lib/woods/extractors/migration_extractor.rb +3 -1
  96. data/lib/woods/extractors/model_extractor.rb +39 -33
  97. data/lib/woods/extractors/package_extractor.rb +24 -4
  98. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  99. data/lib/woods/extractors/policy_extractor.rb +3 -1
  100. data/lib/woods/extractors/poro_extractor.rb +3 -1
  101. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  102. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  103. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  104. data/lib/woods/extractors/route_extractor.rb +3 -1
  105. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  106. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  107. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  108. data/lib/woods/extractors/service_extractor.rb +3 -1
  109. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  110. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  111. data/lib/woods/extractors/source_nesting.rb +1 -1
  112. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  113. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  114. data/lib/woods/extractors/validator_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  116. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  117. data/lib/woods/flow_assembler.rb +87 -8
  118. data/lib/woods/flow_precomputer.rb +44 -7
  119. data/lib/woods/gem_mapper.rb +2 -0
  120. data/lib/woods/git_history.rb +116 -0
  121. data/lib/woods/graph_analyzer.rb +195 -63
  122. data/lib/woods/hooks/context_cli.rb +54 -0
  123. data/lib/woods/hooks/context_event.rb +88 -0
  124. data/lib/woods/hooks/context_hint.rb +73 -0
  125. data/lib/woods/hooks/context_impact.rb +77 -0
  126. data/lib/woods/hooks/context_output.rb +47 -0
  127. data/lib/woods/hooks/context_state.rb +102 -0
  128. data/lib/woods/hooks/refresh.rb +79 -0
  129. data/lib/woods/hooks/rule_projection.rb +78 -0
  130. data/lib/woods/input_rules.rb +19 -0
  131. data/lib/woods/mcp/bearer_auth.rb +20 -12
  132. data/lib/woods/mcp/bootstrapper.rb +62 -0
  133. data/lib/woods/mcp/index_reader.rb +323 -160
  134. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  135. data/lib/woods/mcp/origin_guard.rb +17 -9
  136. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  137. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  138. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  139. data/lib/woods/mcp/search_results.rb +74 -0
  140. data/lib/woods/mcp/server.rb +158 -37
  141. data/lib/woods/mcp/tool_contract.rb +2 -0
  142. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  143. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  144. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  145. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  146. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  147. data/lib/woods/notion/exporter.rb +56 -17
  148. data/lib/woods/obsidian/destination_plan.rb +98 -0
  149. data/lib/woods/obsidian/name_mapper.rb +19 -3
  150. data/lib/woods/obsidian/note_builder.rb +19 -10
  151. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  152. data/lib/woods/operator/pipeline_guard.rb +18 -13
  153. data/lib/woods/path_dispatcher.rb +7 -1
  154. data/lib/woods/payload_store.rb +29 -15
  155. data/lib/woods/railtie.rb +3 -3
  156. data/lib/woods/railtie_support.rb +12 -12
  157. data/lib/woods/rake_helpers.rb +392 -0
  158. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  159. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  160. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  161. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  162. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  163. data/lib/woods/resilience/index_validator.rb +112 -23
  164. data/lib/woods/retrieval/context_assembler.rb +50 -15
  165. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  166. data/lib/woods/retrieval/lexical_index.rb +119 -0
  167. data/lib/woods/retrieval/ranker.rb +4 -2
  168. data/lib/woods/retrieval/scope.rb +108 -0
  169. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  170. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  171. data/lib/woods/retrieval/search_executor.rb +86 -27
  172. data/lib/woods/retrieval/source_evidence.rb +200 -0
  173. data/lib/woods/retriever.rb +98 -22
  174. data/lib/woods/ruby_analyzer/trace_enricher.rb +80 -38
  175. data/lib/woods/session_tracer/middleware.rb +10 -12
  176. data/lib/woods/session_tracer/redis_store.rb +22 -6
  177. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  178. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  179. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  180. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  181. data/lib/woods/source_inputs/handoff.rb +102 -0
  182. data/lib/woods/source_inputs/launcher.rb +157 -0
  183. data/lib/woods/source_inputs/manifest.rb +124 -0
  184. data/lib/woods/source_inputs/private_key.rb +55 -0
  185. data/lib/woods/source_inputs/scanner.rb +171 -0
  186. data/lib/woods/source_inputs/scopes.rb +71 -0
  187. data/lib/woods/source_inputs/session.rb +214 -0
  188. data/lib/woods/source_inputs/status.rb +84 -0
  189. data/lib/woods/source_inputs/verifier.rb +107 -0
  190. data/lib/woods/storage/metadata_store.rb +25 -25
  191. data/lib/woods/storage/pgvector.rb +29 -8
  192. data/lib/woods/storage/qdrant.rb +17 -7
  193. data/lib/woods/storage/vector_store.rb +18 -6
  194. data/lib/woods/tasks.rb +3 -2
  195. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  196. data/lib/woods/unblocked/exporter.rb +59 -70
  197. data/lib/woods/version.rb +1 -1
  198. data/lib/woods/watch/boot_snapshot.rb +52 -0
  199. data/lib/woods/watch/daemon.rb +136 -28
  200. data/lib/woods/watch/listen_watcher.rb +4 -0
  201. data/lib/woods/watch/polling_watcher.rb +5 -1
  202. data/lib/woods/watch/status.rb +20 -15
  203. data/lib/woods/watch/tree_scan.rb +21 -13
  204. data/lib/woods/watch/watcher.rb +4 -1
  205. data/lib/woods.rb +135 -11
  206. data/plugin/.claude-plugin/plugin.json +1 -1
  207. data/plugin/hooks/adapters/normalize.jq +15 -0
  208. data/plugin/hooks/adapters/normalize.rb +63 -0
  209. data/plugin/hooks/hooks.json +20 -0
  210. data/plugin/hooks/woods-context.sh +50 -0
  211. data/plugin/hooks/woods-input-rules.sh +159 -0
  212. data/plugin/hooks/woods-opencode.mjs +65 -0
  213. data/plugin/hooks/woods-post-edit.sh +2 -225
  214. data/plugin/hooks/woods-refresh.sh +260 -0
  215. data/plugin/hooks/woods-session-start.sh +47 -55
  216. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  217. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  218. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  219. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  220. data/plugin/skills/woods-setup/SKILL.md +107 -6
  221. metadata +84 -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.
@@ -151,6 +160,21 @@ it starts: edits and pulled commits that landed while nothing was watching are
151
160
  invisible to it forever. That matters because callers stand down when a daemon
152
161
  is alive, so *alive has to mean covered*.
153
162
 
163
+ The standalone `woods:watch` task snapshots reload/restart inputs before invoking
164
+ Rails' `environment` task. Inputs unchanged across that boundary, including
165
+ carried paths that remain deleted, may be reconciled by a full extraction.
166
+ Changes during environment initialization still require restart. Lock contention,
167
+ extraction failure, and publication failure retain the full-reconciliation
168
+ obligation for retry; a successful publish clears it.
169
+
170
+ This boundary covers **environment initialization**. Bundler and
171
+ `config/application.rb` can run before the task begins; the snapshot does not
172
+ prove that edits during those earlier stages were incorporated. Start the task
173
+ against a settled boot configuration. If Rails is already initialized or the
174
+ `environment` task was already invoked, the daemon keeps conservative restart
175
+ handling. Use `bundle exec rake woods:watch` as a separate process, rather than
176
+ `bundle exec rake environment woods:watch`.
177
+
154
178
  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
179
  known good", and everything modified since is uncovered, whoever changed it.
156
180
  With no generation file there is no index, every file is uncovered, and the
@@ -161,7 +185,12 @@ external cleanup targeting the large directories), and readers deliberately
161
185
  degrade a dangling pointer to the index root, so trusting the mtime there would
162
186
  report "current at startup" over a directory holding nothing.
163
187
 
164
- **The watcher thread starts before this reconciliation runs, not after.** A
188
+ **The built-in watcher establishes detection before reconciliation runs.**
189
+ Polling signals readiness after its baseline scan; native watching signals after
190
+ listener startup, including a fallback to polling. Startup waits up to 30 seconds
191
+ for readiness and reports an error if detection cannot start. Callbacks enqueue
192
+ live events immediately, while extraction waits until startup obligations are
193
+ established. A
165
194
  file saved while catch-up's own extraction is still in flight (which can take
166
195
  minutes on a storm-triggered full run) used to be lost twice: no watcher
167
196
  existed yet to see it, and the polling watcher takes its baseline snapshot
@@ -185,7 +214,7 @@ supplies the trigger.
185
214
  This is what makes the documented hook pattern safe:
186
215
 
187
216
  ```bash
188
- bundle exec rake woods:watch_status || start_the_daemon
217
+ bundle exec rake woods:watch_status || start_the_daemon # same host; see cross-host liveness below
189
218
  bundle exec rake woods:incremental # stands down, the daemon has these
190
219
  ```
191
220
 
@@ -215,9 +244,19 @@ polling rather than trust a watcher that may sit silent while files change
215
244
  under it:
216
245
 
217
246
  ```bash
218
- WOODS_WATCH_POLL=1 bundle exec rake woods:watch
247
+ WOODS_WATCH_POLL=1 WOODS_WATCH_POLL_INTERVAL=2.5 bundle exec rake woods:watch
219
248
  ```
220
249
 
250
+ ### Polling cost
251
+
252
+ For a slow bind mount, increase `WOODS_WATCH_POLL_INTERVAL` to reduce scan
253
+ frequency. The default is 1.0 second; the example above uses 2.5 seconds.
254
+ Each cycle also includes the scan's duration. Longer intervals can delay
255
+ change detection and polling shutdown. The value also applies when a native
256
+ watcher fails and falls back to polling; it has no effect while native watching
257
+ is active. Blank, malformed, nonfinite, zero and negative values are rejected
258
+ before the daemon starts.
259
+
221
260
  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
261
  daemon logs it and falls back to polling rather than exiting, because a daemon
223
262
  costing some CPU beats one that never fires. Failures *after* startup are not
@@ -234,6 +273,14 @@ Ignored by default: `.git`, `node_modules`, `tmp`, `log`, `coverage`,
234
273
  `vendor/bundle`, `public/assets`, `public/packs`, `storage`. That ignore list is
235
274
  what keeps a polling scan bounded.
236
275
 
276
+ Polling and startup catch-up preserve each logical path when multiple directory
277
+ symlinks point to the same source tree. For example, `a_shared/user.rb` and
278
+ `app/models/user.rb` both remain visible; an earlier alias must not hide the path
279
+ that extraction recognizes. Cycles back to a directory already on the current
280
+ traversal branch are pruned, while independent sibling aliases remain visible.
281
+ This can increase scan work for deliberately repeated aliases; avoid unnecessary
282
+ aliases in large watched trees. Ignored logical paths are still pruned.
283
+
237
284
  ## Placement
238
285
 
239
286
  The spike asked for three placements to be compared and one chosen. Every
@@ -418,13 +465,11 @@ party) behaves exactly as it always did.
418
465
  was told the index matched HEAD while every answer described the tree before
419
466
  those edits.
420
467
 
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".
468
+ The fingerprint hashes the current `git status --porcelain` path/status list.
469
+ Repeated edits to the same already-dirty file can leave it identical. It is not
470
+ content identity. Use `index.source_freshness` for generation-bound content
471
+ verification; see [source freshness](SOURCE_FRESHNESS.md) for `current`, `drifted`,
472
+ `unknown`, scan budgets, fresh-process capture and partial-runtime limitations.
428
473
 
429
474
  ### Multi-file read consistency
430
475
 
@@ -534,7 +579,7 @@ and a hook-triggered `woods:incremental`. They share the existing file-based
534
579
  A hook can check cheaply:
535
580
 
536
581
  ```bash
537
- bundle exec rake woods:watch_status || start_the_daemon # exit 0 = alive
582
+ bundle exec rake woods:watch_status || start_the_daemon # same host, exit 0 = alive
538
583
  ```
539
584
 
540
585
  The check does not boot Rails. Without `WOODS_OUTPUT`, it resolves
@@ -543,63 +588,145 @@ launcher's current directory, so `rake -f /app/Rakefile woods:watch_status`
543
588
  and worktree-manager invocations inspect the same per-app status. Set
544
589
  `WOODS_OUTPUT` when the daemon uses a non-default index directory.
545
590
 
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).
591
+ ### Cross-host liveness
550
592
 
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.
593
+ By default, a reader trusts only a same-host record: a `running` or `degraded`
594
+ state, a positive pid that still exists, and a recent ISO8601 timestamp. Foreign
595
+ hostnames are rejected because a container pid cannot be checked on the host.
596
+
597
+ For a daemon and reader sharing the same index through a bind mount, opt in in
598
+ each reader's environment:
599
+
600
+ ```bash
601
+ export WOODS_WATCH_TRUST_FOREIGN_HOST=1
602
+ bundle exec rake woods:watch_status || start_the_daemon
603
+ ```
604
+
605
+ Set the variable inside one-off containers running `woods:incremental`, and in
606
+ the host MCP process when it reports `woods_status`. Docker does not forward a
607
+ host environment variable automatically: pass `-e WOODS_WATCH_TRUST_FOREIGN_HOST=1`
608
+ to `docker compose run` or `docker compose exec`, or configure that service's
609
+ environment. Use the same shared index (`WOODS_OUTPUT` when needed) in each process.
610
+
611
+ Opted-in readers accept foreign `running` and `degraded` records on heartbeat
612
+ freshness, without any local pid lookup. Heartbeats run every five minutes; a
613
+ crashed foreign daemon can still be believed for up to 15 minutes after its last
614
+ heartbeat. Missing or malformed timestamps and timestamps more than 30 seconds
615
+ in the future are rejected. Keep the participating clocks synchronized.
616
+
617
+ `degraded` means alive but unable to update: `watch_status` exits 0, incremental
618
+ still attempts extraction, and clean refuses. `WOODS_IGNORE_WATCH=1` still
619
+ overrides writer stand-down and clean protection. It does not alter the status
620
+ report. Direct Ruby callers can override the environment with
621
+ `Status#alive?(trust_foreign_host: true)` or `false`.
622
+
623
+ This is liveness evidence for an established daemon, not a cross-container
624
+ startup lease. Simultaneous starts in foreign namespaces still need one
625
+ supervisor to coordinate ownership. Hostnames are also imperfect identity:
626
+ custom or reused identical container hostnames retain the local-pid limitation.
558
627
 
559
628
  ### Hooks for agent sessions
560
629
 
630
+ For client registration and the supported Claude/OpenCode event shapes, see
631
+ [edit client adapters](CLIENT_HOOKS.md). Both use the shared queue below.
632
+
561
633
  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
634
+ with no daemon needs a different trigger, so the Woods plugin ships two freshness
563
635
  hooks (`plugin/hooks/hooks.json`), both shipped disabled:
564
636
 
565
637
  | Hook | When | What it does |
566
638
  |---|---|---|
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.
639
+ | `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` |
640
+ | `SessionStart` (`startup`, `resume`) | Session begins | Checks source content through `woods:source_status`; warns on drift or unknown evidence |
641
+
642
+ Both read `cwd` from the hook payload, so a linked worktree uses its own index.
643
+ Both require an existing `generation.json` and `WOODS_HOOKS_ENABLED=1`;
644
+ `WOODS_HOOKS_DISABLED=1` overrides enablement. The broader refresh task is
645
+ **unreleased after Woods 2.0.0.beta2**. Check the installed gem's task list
646
+ (`bundle exec rake -T woods:hook_refresh`, through the application container
647
+ when appropriate) before enabling this plugin version. An older gem's unknown
648
+ task error leaves queued events in place; installing the plugin does not upgrade
649
+ the gem.
650
+
651
+ The portable path predicate is generated from `PathDispatcher` and
652
+ `ReloadPolicy` with `bundle exec ruby -Ilib script/generate-hook-rules`.
653
+ A contract test rejects stale generated rules. It covers services, controllers,
654
+ jobs, concerns, views, locales, supported test/lib files, routes, package
655
+ boundaries, and the remaining standard extractor triggers. Unrelated documents
656
+ stay quiet. Normal edits use fresh-process incremental extraction; changes to
657
+ initializers, boot configuration, dependencies, schema, or other restart inputs
658
+ use fresh-process full extraction. The transport also preserves explicit
659
+ `add`, `update`, `delete`, and `move` operations; a relevant deletion/move selects
660
+ full extraction to remove runtime classes absent from the next boot. The Claude
661
+ adapter receives one `tool_input.file_path`. The OpenCode adapter supplies every
662
+ verified patch metadata path, including both rename sides. Neither infers paths
663
+ from shell commands or parses patch text. Custom runtime roots outside the
664
+ standard dispatcher rules require an explicit refresh; the portable predicate
665
+ cannot discover application configuration without booting it.
666
+
667
+ `WOODS_HOOK_RAKE` sets the command prefix (Docker:
668
+ `docker compose exec -T app bundle exec rake`). The encoded JSON task argument
669
+ carries paths and the output setting across the container boundary, without
670
+ assuming Docker forwards host environment variables or can read a host queue
671
+ filename. The host needs Bash 3.2 or later, standard Unix tools, and either `jq`
672
+ or Ruby; it does not need the application bundle. Prefix words are split without
673
+ shell evaluation: use an executable wrapper for quoted arguments or extra
674
+ container environment settings. `WOODS_OUTPUT` overrides `tmp/woods`, relative
675
+ to each process's application root or as an explicitly supplied absolute path;
676
+ absolute paths must be valid on both sides of a container bind mount.
677
+
678
+ Each event remains under `<output>/hook-pending/` until the task succeeds.
679
+ Successful no-op consumption is acknowledged too. Contending invocations enqueue
680
+ and return while the owner drains bounded batches (up to 16 queue files /
681
+ 1,000 paths / 48 KiB of JSON, without splitting a multi-file event). Commas,
682
+ spaces, and newlines are preserved. An empty drain releases the lock before
683
+ checking again, so a final arriving event can acquire ownership.
684
+ A failed command, killed worker, incompatible gem, or publication failure retains
685
+ its batch for retry: delivery is **at least once**, so crash recovery can repeat
686
+ already completed work. Pending events in the previous `hook-pending.txt` format
687
+ are imported on the next relevant edit. Event filenames are private hook state;
688
+ do not modify them while a worker is running.
689
+
690
+ An active daemon produces exit **75** before Rails boots. This is a deferral,
691
+ not acknowledgement: the hook cannot prove which queued events the daemon has
692
+ consumed. It retains the queue and writes a diagnostic. It does not start, stop,
693
+ or restart the daemon. After resolving the cause, the next relevant edit retries
694
+ the queue. To retry immediately, invoke `woods-post-edit.sh` with the original
695
+ JSON event on stdin and the same opt-in/output/prefix settings; with a running
696
+ daemon, stop it first or explicitly configure `WOODS_IGNORE_WATCH=1` in the
697
+ application command's environment. A quiet `SessionStart` does not acknowledge
698
+ the queue. Prefer a resident watcher for sustained edits; enabling both does not
699
+ make refresh faster and can accumulate deferred events.
700
+
701
+ After the complete event input has been read, validated, and queued, the refresh
702
+ hook starts its `WOODS_HOOK_TIMEOUT_SECONDS` deadline (default 600, integer range
703
+ 1–3600), including subsequent batches. The producer must close stdin: the 1 MiB
704
+ input limit bounds bytes, not time waiting for EOF. The deadline terminates the local
705
+ command process group and retains work on timeout. For a Docker exec prefix,
706
+ local process termination cannot guarantee cancellation inside the container;
707
+ check the application process and extraction lock before retrying a timed-out
708
+ container run. Async client hook timeouts are not a reliable worker deadline.
709
+ With `flock`, kernel locks release after process exit. The mkdir fallback records
710
+ an owner PID and reclaims dead owners; it never steals a live owner's lock based
711
+ only on age. Only the invocation that removes the recorded dead-owner marker
712
+ may replace its lock directory; competing reclaimers leave their events queued
713
+ for the winning owner. Legacy empty lock directories use `stat` and
714
+ `WOODS_HOOK_LOCK_STALE_SECONDS` (default 1800) for conservative recovery.
715
+ A reused PID can delay recovery until that process exits; inspect the recorded
716
+ owner before manually removing a lock. Hooks sharing this filesystem must run in
717
+ the same host PID namespace; run the actual extraction through the container
718
+ prefix instead of running competing host/container hook workers.
719
+
720
+ Broader coverage increases the number of Rails boots. A view or locale edit now
721
+ costs a fresh incremental run, while a boot/config edit costs a full run. There
722
+ is no provider or embedding call added by this hook. Opt in for occasional agent
723
+ edits; use `woods:watch` for repeated work, and keep full extraction for large
724
+ change sets as described above.
725
+
726
+ The `SessionStart` hook uses the shared quick source verifier through
727
+ `WOODS_HOOK_RAKE`. It has a ten-second command deadline, including startup;
728
+ failed commands and old gems lacking `woods:source_status` report unknown.
729
+ It does not initialize Rails or start a provider. See [source freshness](SOURCE_FRESHNESS.md#containers-and-hooks).
603
730
 
604
731
  ### Reader multiplicity is free
605
732
 
@@ -663,5 +790,78 @@ result = daemon.process(changed_paths)
663
790
  # => { action: :incremental, state: :running, generation: 42, count: 1, duration_ms: 61 }
664
791
  ```
665
792
 
793
+ For an embedded `#run`, pass `boot_snapshot: Woods::Watch::BootSnapshot.new(root: …)`
794
+ with the snapshot captured **before** environment initialization if the host can
795
+ establish that boundary. Without it, startup restart inputs remain restart
796
+ requests. Direct `#process` calls always preserve conservative restart handling.
797
+ Injected watchers retain their existing `start`/`stop` interface; those with
798
+ asynchronous startup can implement `ready_callback=` and call it after detection
799
+ is established to participate in the readiness handshake.
800
+
666
801
  `#process` is one whole cycle and is the supported embedding point. `#run` only
667
802
  supplies batches to it.
803
+
804
+ ### Optional bounded context hints
805
+
806
+ Context hints are a separate Claude Code opt-in, **unreleased after Woods
807
+ 2.0.0.beta2**. Verify `bundle exec woods-hook-context --help` in the installed
808
+ application bundle before enabling `WOODS_HOOK_CONTEXT_ENABLED=1`. The plugin
809
+ version alone does not establish gem support. `WOODS_HOOKS_DISABLED=1` disables
810
+ both context and refresh; `WOODS_HOOKS_ENABLED` controls only the existing
811
+ freshness/refresh hooks. Either feature can work without the other.
812
+
813
+ Separate synchronous SessionStart and PostToolUse entries emit Claude's
814
+ `hookSpecificOutput.additionalContext`. SessionStart gives a short served-index
815
+ orientation. After a relevant native Edit/Write/MultiEdit, the hint identifies
816
+ candidates from one retained published generation. Direct candidates and
817
+ transitive candidates are distinguished; test mappings are suggestions, never
818
+ proof of coverage. Post-edit hints always say **pre-refresh snapshot** because
819
+ an edit can precede publication. Source freshness is checked against that same
820
+ payload; unknown/drifted evidence remains explicit. An unresolved or ambiguous
821
+ edited identity directs the agent to manual search and typed lookup. No match
822
+ within the bounded snapshot establishes neither absence nor no impact.
823
+
824
+ Limits are fixed: depth 2, at most 10 visited nodes including the root, 100
825
+ examined edges, and 2 KiB for the **entire JSON output**, preserving whole rows.
826
+ An index is refused above 16 MiB per required artifact or 50,000 combined graph
827
+ nodes/variants. These preparation checks, JSON parsing, cache construction,
828
+ source verification, path/content hashing, formatting and suppression state all
829
+ run within the hook's private process-group deadline: the worker is killed at
830
+ 850 ms, leaving dispatch/cleanup headroom within a one-second work budget.
831
+ The helper also has a 650 ms inner deadline. OS scheduling can delay observation
832
+ of a deadline. Cold bundle/container startup can therefore produce no hint;
833
+ the deadline is not extended. Oversized evidence is marked truncated; missing,
834
+ corrupt, unsupported or timed-out input produces a short unknown notice or
835
+ silence. Silence is never a complete/no-impact claim. The hint boots no Rails
836
+ application and calls no provider.
837
+
838
+ The synchronous opt-in can add up to this budget to a supported tool call. It
839
+ makes context available to Claude's next model request; it does not rely on the
840
+ later-turn delivery of the independent asynchronous refresh worker. A reminder
841
+ need not appear as a visible transcript entry. See the
842
+ [Claude context-output contract](https://code.claude.com/docs/en/hooks#add-context-for-claude).
843
+
844
+ The default command is `bundle exec woods-hook-context`. Set
845
+ `WOODS_HOOK_CONTEXT_COMMAND` to an executable wrapper or argv prefix for a
846
+ container-only bundle. Prefix words are split without shell evaluation; a
847
+ wrapper handles quoted arguments. Explicit `WOODS_HOOK_CONTEXT_ROOT` maps the
848
+ hook payload's original cwd and contained edit path onto a runtime-visible
849
+ application root. For example, a container prefix can include
850
+ `docker compose exec -T -e WOODS_HOOK_CONTEXT_ENABLED=1 -e WOODS_HOOK_CONTEXT_ROOT=/app app bundle exec woods-hook-context`.
851
+ Forward a custom `WOODS_OUTPUT` explicitly too. Running Claude inside the
852
+ application container avoids external container startup and path mapping.
853
+
854
+ Repeat suppression uses session/worktree, served generation/token, changed-file
855
+ content identity and normalized hint content. Repeated identical evidence stays
856
+ quiet; later same-file edits and generation changes can reappear. Missing session
857
+ or bounded content identity disables suppression. Private `hook-context-state.json`
858
+ retains at most 32 sessions and 32 emitted identities per session under a separate
859
+ nonblocking lock. It records **emitted**, not confirmed delivered, hints.
860
+ Contending or unavailable state may skip optional context. Its bounded atomic
861
+ state update never reads, acknowledges, or clears `hook-pending`, nor acquires
862
+ refresh/watch locks. Disable context to roll back without changing refresh.
863
+
864
+ Only the native Claude context entries are supported here; the OpenCode adapter
865
+ continues to provide refresh events. No prompt-triggered retrieval is injected.
866
+
867
+ 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)
@@ -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