woods 2.0.0.beta3 → 2.0.0

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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +500 -420
  3. data/CONTRIBUTING.md +29 -17
  4. data/README.md +78 -178
  5. data/docs/AGENT_GUIDE.md +52 -11
  6. data/docs/AGENT_SETUP.md +34 -17
  7. data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
  8. data/docs/BACKEND_MATRIX.md +18 -7
  9. data/docs/CLIENT_HOOKS.md +1 -1
  10. data/docs/CONFIGURATION_REFERENCE.md +105 -29
  11. data/docs/CONSOLE_MCP_SETUP.md +54 -9
  12. data/docs/DOCKER_SETUP.md +16 -1
  13. data/docs/EVALUATION.md +10 -4
  14. data/docs/EXTRACTOR_REFERENCE.md +23 -3
  15. data/docs/FAQ.md +14 -3
  16. data/docs/GETTING_STARTED.md +18 -17
  17. data/docs/INCREMENTAL_EXTRACTION.md +37 -8
  18. data/docs/INDEX_LAYOUT.md +2 -2
  19. data/docs/MCP_SERVERS.md +79 -7
  20. data/docs/MCP_TOOL_COOKBOOK.md +5 -5
  21. data/docs/MCP_WORKTREE_SETUP.md +55 -83
  22. data/docs/PUBLISHED_INDEX.md +17 -0
  23. data/docs/README.md +2 -1
  24. data/docs/RETRIEVAL_GUIDE.md +81 -13
  25. data/docs/SOURCE_FRESHNESS.md +1 -1
  26. data/docs/TOKEN_BENCHMARK.md +16 -10
  27. data/docs/TROUBLESHOOTING.md +142 -47
  28. data/docs/UPGRADING_TO_2.md +12 -6
  29. data/docs/WATCH_DAEMON.md +189 -24
  30. data/docs/WHY_WOODS.md +9 -5
  31. data/exe/woods-console +13 -11
  32. data/exe/woods-mcp-start +14 -9
  33. data/exe/woods-watch +5 -0
  34. data/lib/generators/woods/pgvector_generator.rb +8 -2
  35. data/lib/generators/woods/watch_generator.rb +53 -0
  36. data/lib/puma/plugin/woods.rb +10 -0
  37. data/lib/tasks/woods.rake +14 -0
  38. data/lib/woods/agent_configuration/applier.rb +5 -3
  39. data/lib/woods/agent_configuration/cli.rb +2 -2
  40. data/lib/woods/agent_configuration/layout.rb +13 -0
  41. data/lib/woods/cache/cache_middleware.rb +6 -0
  42. data/lib/woods/console/credential_scanner.rb +4 -3
  43. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  44. data/lib/woods/console/embedded_executor.rb +31 -9
  45. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  46. data/lib/woods/console/sql_table_scanner.rb +47 -7
  47. data/lib/woods/console/sql_validator.rb +49 -9
  48. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  49. data/lib/woods/console/stdio_transport.rb +27 -0
  50. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  51. data/lib/woods/embedding/indexer.rb +24 -14
  52. data/lib/woods/extractor.rb +70 -19
  53. data/lib/woods/extractors/declared_parent.rb +55 -0
  54. data/lib/woods/extractors/graphql_extractor.rb +2 -11
  55. data/lib/woods/extractors/lib_extractor.rb +10 -8
  56. data/lib/woods/extractors/mailer_extractor.rb +6 -10
  57. data/lib/woods/extractors/model_extractor.rb +1 -15
  58. data/lib/woods/extractors/poro_extractor.rb +10 -8
  59. data/lib/woods/extractors/shared_utility_methods.rb +22 -5
  60. data/lib/woods/git_command.rb +6 -7
  61. data/lib/woods/git_provenance.rb +4 -6
  62. data/lib/woods/mcp/bearer_auth.rb +2 -1
  63. data/lib/woods/mcp/bootstrapper.rb +20 -5
  64. data/lib/woods/mcp/config_resolver.rb +2 -1
  65. data/lib/woods/mcp/index_reader.rb +11 -2
  66. data/lib/woods/mcp/initialization_guidance.rb +1 -1
  67. data/lib/woods/mcp/renderers/markdown_renderer.rb +14 -8
  68. data/lib/woods/mcp/renderers/plain_renderer.rb +11 -7
  69. data/lib/woods/mcp/server.rb +63 -37
  70. data/lib/woods/mcp/tool_contract.rb +1 -1
  71. data/lib/woods/mcp/tool_response_renderer.rb +16 -0
  72. data/lib/woods/mcp/traversal_evidence_text.rb +1 -1
  73. data/lib/woods/mcp/traversal_response.rb +22 -0
  74. data/lib/woods/path_dispatcher.rb +6 -5
  75. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  76. data/lib/woods/published_index.rb +2 -2
  77. data/lib/woods/rake_helpers.rb +2 -12
  78. data/lib/woods/retrieval/corpus_status.rb +46 -0
  79. data/lib/woods/retrieval/lexical_assembler.rb +14 -3
  80. data/lib/woods/retrieval/lexical_index.rb +2 -1
  81. data/lib/woods/retriever.rb +19 -7
  82. data/lib/woods/session_tracer/file_store.rb +6 -1
  83. data/lib/woods/source_inputs/consumer_errors.rb +4 -0
  84. data/lib/woods/storage/local_corpus_stats.rb +32 -0
  85. data/lib/woods/storage/metadata_store.rb +20 -0
  86. data/lib/woods/storage/pgvector.rb +6 -2
  87. data/lib/woods/storage/vector_store.rb +10 -0
  88. data/lib/woods/temporal/json_snapshot_store.rb +35 -7
  89. data/lib/woods/version.rb +1 -1
  90. data/lib/woods/watch/child_environment.rb +30 -0
  91. data/lib/woods/watch/cli.rb +91 -0
  92. data/lib/woods/watch/daemon.rb +73 -11
  93. data/lib/woods/watch/event_stream.rb +70 -0
  94. data/lib/woods/watch/guardian.rb +142 -0
  95. data/lib/woods/watch/installation/layout.rb +70 -0
  96. data/lib/woods/watch/installation/options.rb +128 -0
  97. data/lib/woods/watch/installation/planner.rb +128 -0
  98. data/lib/woods/watch/installation/probe.rb +101 -0
  99. data/lib/woods/watch/installation/receipt.rb +77 -0
  100. data/lib/woods/watch/installation/recovery.rb +64 -0
  101. data/lib/woods/watch/installation/templates.rb +58 -0
  102. data/lib/woods/watch/installation.rb +56 -0
  103. data/lib/woods/watch/lifecycle.rb +182 -0
  104. data/lib/woods/watch/managed_child.rb +113 -0
  105. data/lib/woods/watch/managed_cleanup.rb +48 -0
  106. data/lib/woods/watch/managed_process.rb +144 -0
  107. data/lib/woods/watch/puma_adapter.rb +87 -0
  108. data/lib/woods/watch/puma_child.rb +66 -0
  109. data/lib/woods/watch/supervision_records.rb +95 -0
  110. data/lib/woods/watch/supervision_status.rb +104 -0
  111. data/lib/woods/watch/supervisor.rb +161 -0
  112. data/lib/woods/watch/supervisor_reporting.rb +46 -0
  113. data/plugin/.claude-plugin/plugin.json +1 -1
  114. data/plugin/hooks/woods-input-rules.sh +4 -4
  115. data/plugin/skills/woods-agent-enable/SKILL.md +7 -1
  116. data/plugin/skills/woods-diagnose/SKILL.md +134 -34
  117. data/plugin/skills/woods-investigate/SKILL.md +54 -15
  118. data/plugin/skills/woods-mcp-config/SKILL.md +38 -11
  119. data/plugin/skills/woods-setup/SKILL.md +72 -15
  120. metadata +38 -5
data/docs/WATCH_DAEMON.md CHANGED
@@ -34,15 +34,160 @@ Ctrl-C to stop.
34
34
  | `WOODS_WATCH_CATCH_UP` | `1` | `0` skips the startup reconciliation |
35
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) |
36
36
 
37
- Run it under a supervisor. When boot-captured configuration changes the daemon
38
- exits `75` (`EX_TEMPFAIL`) on purpose, see [Restart triggers](#restart-triggers).
37
+ The raw task needs a supervisor that restarts it after exit `75` (`EX_TEMPFAIL`);
38
+ see [Restart triggers](#restart-triggers). Docker restart policies can supply
39
+ this. A bare task in Foreman cannot: Foreman shuts down its process group when a
40
+ child exits. Use the managed launcher below for native Procfile development.
39
41
 
40
- ```yaml
41
- # Procfile.dev
42
- web: bin/rails server
43
- woods: bundle exec rake woods:watch
42
+ ### Managed development startup
43
+
44
+ **Included in Woods `2.0.0` ([#538](https://github.com/lost-in-the/woods/issues/538)).**
45
+ Record the installed gem path and Git revision as well as VERSION; a checkout
46
+ can have new behavior with the last beta's version. Verify both commands before
47
+ using the new setup:
48
+
49
+ ```bash
50
+ bundle exec woods-watch --help
51
+ bin/rails generate woods:watch --help
52
+ ```
53
+
54
+ Choose one lifecycle owner per application index:
55
+
56
+ | Existing development workflow | Setup |
57
+ |---|---|
58
+ | Rails/Puma, including a `bin/dev` that just starts Rails | Opt-in `puma` mode; the development Puma master starts one separate extraction process, regardless of worker count. |
59
+ | An existing Foreman workflow | `procfile` mode with the exact existing Procfile and selected normal Foreman command. |
60
+ | Docker Compose, Grove, or another supervisor | `external` mode returns the raw task command; configure its lifecycle through the existing supervisor. |
61
+
62
+ For Puma, preview before applying:
63
+
64
+ ```bash
65
+ bin/rails generate woods:watch --mode puma --pretend
66
+ bin/rails generate woods:watch --mode puma
67
+ ```
68
+
69
+ This adds an executable `bin/woods-watch` and an owned directive to
70
+ `config/puma.rb`. The directive loads the plugin only when the active Woods gem
71
+ contains it. An absent gem or an older version without the plugin leaves Puma
72
+ running without a watcher; this also works for Git/path bundles. The adapter
73
+ separately enforces Puma's finalized development environment.
74
+ Starting a console, task, or production
75
+ Puma does not start a watcher. The watcher still uses another Rails process and its associated memory.
76
+ Puma installation checks the application's installed Puma version (supported
77
+ majors: 6, 7, and 8 on native Unix Ruby) and selects the normal `bin/rails server`
78
+ path using `config/puma.rb`. If `config/puma/development.rb` exists, setup refuses
79
+ because Puma would choose it first. Custom `-C` configurations need an explicit
80
+ external/Foreman arrangement; this generator does not discover or rewrite them.
81
+
82
+ For an existing Foreman arrangement, select the command you will actually use:
83
+
84
+ ```bash
85
+ bin/rails generate woods:watch --mode procfile --procfile Procfile.dev \
86
+ --manager-command 'foreman start -f Procfile.dev' --pretend
87
+ ```
88
+
89
+ Repeat without `--pretend` to apply. Preflight checks task discovery and Foreman
90
+ availability with bounded commands; it does not start services. Installation
91
+ preserves `bin/dev` and every unowned service. If `bin/dev` only runs Rails, it
92
+ will still only run Rails: choose Puma mode or explicitly use the selected
93
+ Foreman command. No process-manager gem is installed automatically.
94
+
95
+ Run setup with the same application environment as normal startup. Preflight
96
+ preserves environment-based Bundler configuration, including `BUNDLE_PATH`,
97
+ `BUNDLE_APP_CONFIG`, and excluded groups, while resetting inherited activation
98
+ state and requesting frozen resolution. Earlier Git builds of this
99
+ installer dropped those settings ([#540](https://github.com/lost-in-the/woods/issues/540)).
100
+ If normal task discovery works but installer preflight cannot find Rails or an
101
+ installed dependency, check the loaded revision and bundle environment before
102
+ reinstalling gems or adding a persistent `.bundle/config` workaround.
103
+
104
+ Installer refusals return a nonzero exit status with their diagnostic (#544,
105
+ included in Woods `2.0.0`). Earlier Git builds printed the refusal but exited
106
+ successfully, so automation using those builds must also check the diagnostic and
107
+ whether installation actually applied.
108
+
109
+ Use `--child-command 'bundle exec rails woods:watch'` when that is the
110
+ application's actual Rails entrypoint. Command strings become explicit argument
111
+ vectors; shell pipelines, variable expansion, and shell setup scripts are not
112
+ supported. Do not prepend an `environment` task or an already booted Rails runner.
113
+
114
+ ### Ownership, updates, and removal
115
+
116
+ The generator records relative owned paths and fingerprints in `.woods-watch.json`.
117
+ Commit it with the generated configuration so a new clone/worktree can update or
118
+ remove that setup. Runtime transaction state stays under `tmp/woods-watch-install/`;
119
+ keep that directory ignored. Changes to owned content or executable permissions
120
+ cause a conflict rather than an overwrite. Review the conflict and restore or
121
+ adapt the owned setup explicitly; do not delete the receipt to force an overwrite.
122
+
123
+ ```bash
124
+ # Change modes or update owned setup (include the selected mode's options).
125
+ bin/rails generate woods:watch --operation update --mode puma --pretend
126
+ # Remove owned startup fragments and wrapper; preserve application edits.
127
+ bin/rails generate woods:watch --operation remove --pretend
44
128
  ```
45
129
 
130
+ Repeat without `--pretend` to apply. Stop an existing watcher through its current
131
+ manager before changing owners; the generator does not stop running processes.
132
+ Explicit updates refresh owned directives in place, preserving surrounding text
133
+ and the block's newline style. Repeating setup preserves the existing directive.
134
+ Earlier Git builds used a Puma guard that checked only whether Woods was loaded;
135
+ use the update command above with a supporting bundle to install the capability
136
+ guard ([#542](https://github.com/lost-in-the/woods/issues/542), included in Woods
137
+ `2.0.0`). Do not hand-edit the owned block or receipt.
138
+
139
+ For a permanent downgrade, remove the startup configuration while the supporting
140
+ gem is still installed. The updated Puma guard lets a branch with an older gem
141
+ boot without indexing; it does not make `bin/woods-watch` or a Foreman entry
142
+ compatible with that gem.
143
+ For Docker/Grove, see [automatic maintenance](AUTOMATIC_MAINTENANCE.md).
144
+
145
+ An interrupted apply retains its local transaction journal. When Rails boots,
146
+ preview recovery with `bin/rails generate woods:watch --operation recover
147
+ --pretend`, then repeat without `--pretend`. The Rails command boots the application
148
+ before invoking the generator; its `--pretend` prevents installation writes,
149
+ not application initialization.
150
+
151
+ If an initializer prevents Rails boot, run the boot-free recovery helper directly
152
+ from the application root using its bundle:
153
+
154
+ ```bash
155
+ bundle exec ruby -rwoods/watch/installation -e \
156
+ 'puts Woods::Watch::Installation.new(root: Dir.pwd).recover(pretend: true)'
157
+ ```
158
+
159
+ Repeat with `pretend: false` to restore the recorded transaction. Recovery checks
160
+ snapshots and changes only owned files; concurrent edits keep the journal for
161
+ manual resolution. The helper does not load Rails or run task probes. Do not
162
+ delete a pending journal to force setup.
163
+
164
+ ### Managed restart and failure behavior
165
+
166
+ `woods-watch -- bin/rails woods:watch` stays outside Rails and starts a fresh child
167
+ for each planned restart. It absorbs exit 75 so Foreman/Puma remain running.
168
+ Managed launching requires POSIX process groups and `fork` (Linux/macOS Ruby).
169
+ Other platforms should use the raw task with an external supervisor.
170
+ Boot failures retry with bounded backoff; a failed extraction leaves the last
171
+ good generation readable. `--boot-timeout SECONDS` defaults to 300 seconds and
172
+ bounds boot/handshake, not a valid extraction or writer-lock wait.
173
+
174
+ Managed mode requires `WOODS_WATCH_IDLE_TIMEOUT` to be unset: automatic maintenance
175
+ needs a resident child. An unexpected clean stop, incompatible child, or another
176
+ owner parks the launcher with a diagnostic until its normal owner restarts it.
177
+ There is no automatic ownership takeover. Managed duplicate prevention is scoped
178
+ to one host/process namespace; do not mix raw and managed writers across different
179
+ containers sharing an index. Use one external owner for that arrangement.
180
+
181
+ Daemon liveness, completed startup reconciliation, and source freshness remain
182
+ different facts. Managed supervision records under `watch_supervisors/` expose
183
+ starting/retrying/parked state separately in `woods_status`; an alive supervisor
184
+ without a maintaining child does not provide daemon coverage. If the first Rails
185
+ boot fails before resolving the configured index path, diagnostics are logs-only.
186
+
187
+ Verify startup catch-up, an edit, and an initializer restart through the existing
188
+ MCP connection before declaring automatic maintenance active. With worktree
189
+ management, also verify source and index mounts switch together.
190
+
46
191
  ## One cycle
47
192
 
48
193
  ```
@@ -96,7 +241,7 @@ partial write:
96
241
 
97
242
  | Failure | What happens |
98
243
  |---|---|
99
- | Reload raises (`SyntaxError`, `NameError`) | Degraded status naming the reason; index intact at generation N; retried on the next event |
244
+ | Reload raises (`SyntaxError`, `NameError`) | Degraded status naming the reason; index intact at generation N; pending paths retried on the next file event or heartbeat |
100
245
  | Extraction raises | Degraded status; generation not advanced |
101
246
  | 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) |
102
247
  | 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 |
@@ -119,17 +264,23 @@ at a known generation, reason attached), `stopped` (nothing is maintaining this
119
264
  index). A stale answer is only dangerous when nothing says so.
120
265
 
121
266
  The file is written world-readable (0644) by design: host-side hooks read it
122
- through a bind mount. Every other artifact Woods writes stays at 0600.
267
+ through a bind mount. Writes through `Woods::AtomicFile` default to owner-only
268
+ 0600 unless the caller supplies another mode. This is not a guarantee for every
269
+ Woods artifact: the SQLite metadata store does not enforce 0600, and a newly
270
+ created database uses 0644 under umask 022. Restrict access to the output
271
+ directory according to the source and metadata it contains.
123
272
 
124
273
  Note that `SyntaxError` is a `ScriptError`, not a `StandardError`. Rescuing
125
274
  only the latter would let a half-typed file kill the daemon.
126
275
 
127
276
  A cycle that fails to land its work never loses its paths. Lock contention, a
128
277
  failed reload, and a raising extraction all carry the batch into `@pending`, and
129
- the next cycle folds it back in, the files really did change, and no later
130
- event will mention them again. The retry is not a tight loop: a degraded cycle
131
- ends the drain and waits for the next event, because the cause needs an edit to
132
- clear.
278
+ the next cycle folds it back in even if no new event mentions those files.
279
+ A degraded cycle ends the current drain to avoid a tight retry loop. Pending
280
+ paths are retried on the next file event or [heartbeat](#the-heartbeat), so a
281
+ finished contending writer does not require another edit to trigger recovery.
282
+ Heartbeat retries use a separate worker so status updates and lock refresh
283
+ continue while extraction runs.
133
284
 
134
285
  ### The heartbeat
135
286
 
@@ -163,6 +314,9 @@ is alive, so *alive has to mean covered*.
163
314
  The standalone `woods:watch` task snapshots reload/restart inputs before invoking
164
315
  Rails' `environment` task. Inputs unchanged across that boundary, including
165
316
  carried paths that remain deleted, may be reconciled by a full extraction.
317
+ Included in Woods `2.0.0`: registered restart inputs deleted while the
318
+ daemon was stopped also trigger a full extraction after a fresh environment
319
+ boot. Nominal framework paths still use the bounded deletion sweep.
166
320
  Changes during environment initialization still require restart. Lock contention,
167
321
  extraction failure, and publication failure retain the full-reconciliation
168
322
  obligation for retry; a successful publish clears it.
@@ -202,8 +356,9 @@ already tolerate the duplicate paths this produces against whatever catch-up
202
356
  finds on its own via the tree scan.
203
357
 
204
358
  Deletions need one extra step, because a deleted file leaves no mtime to scan:
205
- if any path the index attributes a unit to is gone from disk, the daemon runs
206
- one cycle with an *empty* change set, which reaches the ghost units through the
359
+ registered restart inputs follow the full-reconciliation rule above. For other
360
+ registered paths gone from disk, a deletion-only startup runs one cycle with an
361
+ *empty* change set, which reaches the ghost units through the
207
362
  extractor's bounded deletion sweep. Deliberately empty, naming the paths would
208
363
  make the deletions authoritative for every unit type, and some registered paths
209
364
  are nominal (on Rails < 7.1, `ActiveRecord::SchemaMigration` registers a
@@ -273,6 +428,14 @@ Ignored by default: `.git`, `node_modules`, `tmp`, `log`, `coverage`,
273
428
  `vendor/bundle`, `public/assets`, `public/packs`, `storage`. That ignore list is
274
429
  what keeps a polling scan bounded.
275
430
 
431
+ A commit, ref update, or other Git-only change does not necessarily trigger a
432
+ source-file event. The watcher can therefore keep source current while the
433
+ published Git history or provenance still describes an earlier commit. Run full
434
+ `woods:extract` when current Git metadata is required; incremental extraction
435
+ refreshes history only for rewritten units. For containerized linked worktrees,
436
+ verify [Git directory selection](TROUBLESHOOTING.md#git-directory-mounts-for-linked-worktrees)
437
+ as well as source and index mounts.
438
+
276
439
  Polling and startup catch-up preserve each logical path when multiple directory
277
440
  symlinks point to the same source tree. For example, `a_shared/user.rb` and
278
441
  `app/models/user.rb` both remain visible; an earlier alias must not hide the path
@@ -283,15 +446,15 @@ aliases in large watched trees. Ignored logical paths are still pruned.
283
446
 
284
447
  ## Placement
285
448
 
286
- The spike asked for three placements to be compared and one chosen. Every
287
- collaborator on `Woods::Watch::Daemon` is injected, so all three are reachable
288
- from the same class, but the default is **(b), a dedicated daemon per
289
- worktree**:
449
+ Run one dedicated extraction process per active worktree/index. The managed
450
+ Puma adapter and native launcher above retain that separation. Requiring Woods
451
+ or loading its Railtie does not start indexing by itself. Custom integrations
452
+ can use the injected daemon collaborators deliberately:
290
453
 
291
454
  | Option | Verdict |
292
455
  |---|---|
293
456
  | **(b) Dedicated daemon**: *recommended default* | One extra booted app per worktree. Isolated: a crash, a restart, or a storm affects only the index. Lifecycle is drivable from worktree hooks. |
294
- | **(a) Embedded in the dev server** via the Railtie | Marginal memory cost ~0 where a booted app already exists, but couples index freshness to the dev server running and puts extraction on its threads. `Daemon#process` is public precisely so a host can do this deliberately. |
457
+ | **(a) Custom in-process integration** | A host can call public `Daemon#process` deliberately, but must own threading, reloading, and lifecycle. This is not an automatic Railtie feature or the managed Puma adapter. |
295
458
  | **(c) Host watcher + in-container session** | Solves bind-mount event unreliability, but with the most moving parts. Forcing the polling backend solves the same problem with none. |
296
459
 
297
460
  Measured on the fixture app (Ruby 3.3, Rails 8.0):
@@ -642,7 +805,7 @@ hooks (`plugin/hooks/hooks.json`), both shipped disabled:
642
805
  Both read `cwd` from the hook payload, so a linked worktree uses its own index.
643
806
  Both require an existing `generation.json` and `WOODS_HOOKS_ENABLED=1`;
644
807
  `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
808
+ **included in Woods `2.0.0`**. Check the installed gem's task list
646
809
  (`bundle exec rake -T woods:hook_refresh`, through the application container
647
810
  when appropriate) before enabling this plugin version. An older gem's unknown
648
811
  task error leaves queued events in place; installing the plugin does not upgrade
@@ -740,8 +903,10 @@ per-process language-server index does not.
740
903
 
741
904
  N resident daemons is N booted apps, and most slots are dormant most of the
742
905
  time. `idle_timeout` (off by default) stops a daemon after that many seconds
743
- without a file event, so a slot nobody is working in stops holding ~65 MB. A
744
- worktree hook or session start revives it.
906
+ without a file event. Revival requires a separately configured supervisor or
907
+ startup hook; the shipped SessionStart hook only checks freshness. Managed
908
+ `woods-watch` rejects this setting because an idle child shutdown would leave
909
+ automatic maintenance inactive until the owner restarts it.
745
910
 
746
911
  ```ruby
747
912
  Woods::Watch::Daemon.new(output_dir: …, idle_timeout: 900).run # 15 minutes
@@ -803,8 +968,8 @@ supplies batches to it.
803
968
 
804
969
  ### Optional bounded context hints
805
970
 
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
971
+ Context hints are a separate Claude Code opt-in, **included in Woods `2.0.0`**.
972
+ Verify `bundle exec woods-hook-context --help` in the installed
808
973
  application bundle before enabling `WOODS_HOOK_CONTEXT_ENABLED=1`. The plugin
809
974
  version alone does not establish gem support. `WOODS_HOOKS_DISABLED=1` disables
810
975
  both context and refresh; `WOODS_HOOKS_ENABLED` controls only the existing
data/docs/WHY_WOODS.md CHANGED
@@ -56,9 +56,11 @@ It describes what the service does, but misses that `order.save!` triggers `afte
56
56
  :send_confirmation_email` on `Order`, which itself enqueues `InventoryJob` via
57
57
  `after_save :reserve_stock` on `LineItem`.
58
58
 
59
- With Woods, the dependency graph links `CheckoutService` → `Order` → `LineItem` →
60
- `InventoryJob`. A single retrieval call assembles the full execution picture: the service,
61
- the models it touches, the callbacks those models fire, and the jobs those callbacks enqueue.
59
+ With Woods, published graph relationships can connect related services, models,
60
+ callbacks, and jobs for retrieval. Arbitrary method-body constant references are
61
+ not exhaustively indexed, so the whole `CheckoutService` → `Order` → `LineItem` →
62
+ `InventoryJob` chain may not be recorded. Inspect the source and any flow evidence,
63
+ including reported ambiguity and traversal limits, before concluding what runs.
62
64
 
63
65
  ---
64
66
 
@@ -175,11 +177,13 @@ Three tools answered "what is in this Rails app" for coding agents in 2026. Wood
175
177
  | Rubydex (Shopify) | 0.4.1, announced 2026-05-12 | Rust static index of declarations, references, ancestors; experimental `rdx mcp` | Symbol references across a large tree, fast re-index, reported 15 to 80 percent token reduction | Resolved callbacks, inlined concerns, routes as Rails builds them, database partition, churn |
176
178
  | rails-mcp-server | 2.0.0 | Boots the app; `analyze_models`, `get_routes`, `get_schema` | Live model, route, and schema listings over MCP | Callback side effects, request flows, git churn, graph reports, persistent index with generations |
177
179
  | ruby-lsp-rails | 0.5.0.beta1 | Runtime server over `rails runner` for the editor | Model columns, association targets, route info at the cursor | A persistent index other tools can read, graph analysis, multi-database facts |
178
- | Woods | 2.0 | Boots the app once, publishes an atomic JSON generation, serves it without Rails | Resolved runtime behavior on top of structure: inlined concerns, callback side effects, flows, churn, PageRank; database partition and Packwerk boundaries are in progress on this branch | Symbol-level references inside method bodies (Rubydex is the better fit and is complementary) |
180
+ | Woods | 2.0 | Boots the app once, publishes an atomic JSON generation, serves it without Rails | Resolved runtime behavior on top of structure: inlined concerns, callback side effects, flows, churn, PageRank; recorded database partitions and Packwerk boundaries | Symbol-level references inside method bodies (Rubydex is the better fit and is complementary) |
179
181
 
180
182
  Woods and Rubydex are complementary. Rubydex answers "where is this symbol referenced". Woods answers "what happens when this runs, and what does it touch". An agent can use both: Rubydex for references, Woods for behavior, boundaries, and blast radius.
181
183
 
182
- The database-partition layer, in progress on this branch, is the one place Woods will be alone. Rubydex is static, and the other two resolve associations without saying which database each side lives on. See [Extractor reference](EXTRACTOR_REFERENCE.md#modelextractor) for the fields.
184
+ Woods records database-partition metadata on models and Packwerk package ownership
185
+ and boundaries. See [Extractor reference](EXTRACTOR_REFERENCE.md#modelextractor)
186
+ for the fields and their coverage limits.
183
187
 
184
188
  ---
185
189
 
data/exe/woods-console CHANGED
@@ -21,14 +21,14 @@
21
21
  # Check if the rake task already captured stdout for us.
22
22
  protocol_out = $woods_protocol_out # rubocop:disable Style/GlobalVars
23
23
 
24
- unless protocol_out
25
- # Running via rails runner — capture stdout ourselves.
26
- protocol_out = $stdout.dup
27
- $stdout.reopen($stderr)
28
- end
24
+ # Running via rails runner — capture stdout ourselves.
25
+ protocol_out ||= $stdout.dup
26
+ # Keep application logs and writes on stderr for the entire server lifetime.
27
+ $stdout.reopen($stderr)
29
28
 
30
29
  require 'woods'
31
30
  require 'woods/console/server'
31
+ require 'woods/console/stdio_transport'
32
32
 
33
33
  unless Woods.configuration.console_mcp_enabled
34
34
  warn 'Woods Console MCP is disabled. Set ' \
@@ -132,9 +132,11 @@ server = Woods::Console::Server.build_embedded(
132
132
  model_reflections: model_reflections
133
133
  )
134
134
 
135
- # Restore the protocol output for MCP transport.
136
- $stdout.reopen(protocol_out)
137
- protocol_out.close unless protocol_out.closed?
138
-
139
- transport = MCP::Server::Transports::StdioTransport.new(server)
140
- transport.open
135
+ # Only the transport writes to the saved pipe. Restoring process stdout here
136
+ # would also redirect Rails loggers and runtime puts back into the protocol.
137
+ transport = Woods::Console::StdioTransport.new(server, output: protocol_out)
138
+ begin
139
+ transport.open
140
+ ensure
141
+ protocol_out.close unless protocol_out.closed?
142
+ end
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
 
data/exe/woods-watch ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'woods/watch/cli'
5
+ exit Woods::Watch::CLI.new.run(ARGV)
@@ -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'
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'woods/watch/installation'
5
+
6
+ module Woods
7
+ module Generators
8
+ # Reversible opt-in startup integration; never rewrites an application's bin/dev.
9
+ class WatchGenerator < Rails::Generators::Base
10
+ desc 'Install, update, or remove owned Woods watcher startup configuration'
11
+
12
+ class_option :mode, type: :string, desc: 'Explicit startup mode: procfile, puma, or external'
13
+ class_option :operation, type: :string, default: 'setup', desc: 'setup, update, remove, or recover'
14
+ class_option :procfile, type: :string, default: 'Procfile.dev', desc: 'Existing root-level Foreman Procfile'
15
+ class_option :manager_command, type: :string,
16
+ desc: 'Normal Foreman startup argv, e.g. foreman start -f Procfile.dev'
17
+ class_option :child_command, type: :string, default: 'bin/rails woods:watch',
18
+ desc: 'Application task command, parsed as argv (no shell evaluation)'
19
+
20
+ # Rails generators otherwise print Thor errors and return a successful status.
21
+ # @return [Boolean] whether a refused installation fails the CLI command
22
+ def self.exit_on_failure?
23
+ true
24
+ end
25
+
26
+ # @return [void]
27
+ def configure_watcher
28
+ operation = behavior == :revoke ? 'remove' : options[:operation]
29
+ installation = build_installation(operation)
30
+ if operation == 'recover'
31
+ say installation.recover(pretend: options[:pretend])
32
+ return
33
+ end
34
+ plan = installation.plan
35
+ say JSON.pretty_generate(plan.summary)
36
+ return if options[:pretend]
37
+
38
+ say installation.apply(plan)
39
+ say installation.handoff unless operation == 'remove'
40
+ rescue Woods::Watch::Installation::Conflict => e
41
+ raise Thor::Error, e.message
42
+ end
43
+
44
+ private
45
+
46
+ def build_installation(operation)
47
+ Woods::Watch::Installation.new(root: destination_root, mode: options[:mode], operation: operation,
48
+ procfile: options[:procfile], manager_command: options[:manager_command],
49
+ child_command: options[:child_command])
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../woods/watch/puma_adapter'
4
+
5
+ Puma::Plugin.create do
6
+ def start(launcher)
7
+ @woods_adapter ||= Woods::Watch::PumaAdapter.new(launcher)
8
+ @woods_adapter.install
9
+ end
10
+ end
data/lib/tasks/woods.rake CHANGED
@@ -123,6 +123,9 @@ namespace :woods do
123
123
 
124
124
  desc 'Watch the app and keep the index current (resident daemon)'
125
125
  task :watch do
126
+ require 'woods/watch/managed_child'
127
+ managed_child = Woods::Watch::ManagedChild.from_env
128
+ managed_child&.call(:task_loaded, woods_version: Woods::VERSION)
126
129
  # Observe inputs before Rails initializes. A snapshot taken in Daemon.new
127
130
  # would silently bless edits made while initializers were running.
128
131
  require 'woods/watch/boot_snapshot'
@@ -130,6 +133,11 @@ namespace :woods do
130
133
  fresh_environment = !environment.already_invoked && !Rails.application.initialized?
131
134
  boot_snapshot = Woods::Watch::BootSnapshot.new(root: Rails.root) if fresh_environment
132
135
  environment.invoke
136
+ if managed_child && !Rails.env.development?
137
+ managed_child.call(:terminal, reason: 'unsupported_environment')
138
+ warn 'Managed Woods watching requires the finalized Rails development environment.'
139
+ next
140
+ end
133
141
  # Both, and the extractor is not optional. The daemon's default
134
142
  # extractor_factory names Woods::Extractor lazily, so omitting this require
135
143
  # loaded and started cleanly and then NameError'd on the first real cycle —
@@ -140,6 +148,7 @@ namespace :woods do
140
148
  require 'woods/watch/daemon'
141
149
 
142
150
  output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
151
+ managed_child&.call(:identity, root: File.expand_path(Rails.root.to_s), index: File.expand_path(output_dir.to_s))
143
152
 
144
153
  poll_interval = begin
145
154
  Float(ENV.fetch('WOODS_WATCH_POLL_INTERVAL', Woods::Watch::Watcher::DEFAULT_POLL_INTERVAL))
@@ -162,6 +171,8 @@ namespace :woods do
162
171
  idle_timeout: ENV.fetch('WOODS_WATCH_IDLE_TIMEOUT', nil) && Float(ENV.fetch('WOODS_WATCH_IDLE_TIMEOUT')),
163
172
  catch_up: ENV['WOODS_WATCH_CATCH_UP'] != '0',
164
173
  boot_snapshot: boot_snapshot,
174
+ lifecycle: managed_child,
175
+ conservative_claims: !managed_child.nil?,
165
176
  logger: Rails.logger
166
177
  )
167
178
 
@@ -182,6 +193,7 @@ namespace :woods do
182
193
  puts
183
194
 
184
195
  reason = daemon.run
196
+ managed_child&.call(:terminal, reason: reason.to_s)
185
197
 
186
198
  if reason == :restart_required
187
199
  # Boot-captured state changed; Rails cannot reload it. Exit non-zero so
@@ -192,6 +204,8 @@ namespace :woods do
192
204
  end
193
205
 
194
206
  puts 'Watcher stopped.'
207
+ ensure
208
+ managed_child&.close
195
209
  end
196
210
 
197
211
  desc 'Keep watch over the woods — resident index daemon (alias for watch)'
@@ -50,8 +50,10 @@ module Woods
50
50
  "#{@layout.receipt_path}.pending"
51
51
  end
52
52
 
53
- def with_lock
54
- path = "#{@layout.receipt_path}.lock"
53
+ def with_lock(paths = @layout.lock_paths, &operation)
54
+ return operation.call if paths.empty?
55
+
56
+ path, *remaining = paths
55
57
  Document.validate_path!(path)
56
58
  FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
57
59
  File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600) do |lock|
@@ -61,7 +63,7 @@ module Woods
61
63
  'Another Woods configuration operation is active; retry after it finishes'
62
64
  end
63
65
 
64
- yield
66
+ with_lock(remaining, &operation)
65
67
  ensure
66
68
  lock&.flock(File::LOCK_UN)
67
69
  end
@@ -78,12 +78,12 @@ module Woods
78
78
  write_plan(path, plan, layout)
79
79
  PlanDiff.show(plan, @stderr) if options[:diff]
80
80
  plan.summary.merge('plan_file' => File.expand_path(path),
81
- 'runtime_files' => ["#{layout.receipt_path}.lock", "#{layout.receipt_path}.pending"])
81
+ 'runtime_files' => layout.runtime_paths)
82
82
  end
83
83
 
84
84
  def write_plan(path, plan, layout)
85
85
  target = File.expand_path(path)
86
- protected_paths = layout.allowed_paths + ["#{layout.receipt_path}.lock", "#{layout.receipt_path}.pending"]
86
+ protected_paths = layout.allowed_paths + layout.runtime_paths
87
87
  raise Conflict, 'Plan output must differ from every managed/runtime target' if protected_paths.include?(target)
88
88
 
89
89
  Document.validate_path!(target)
@@ -50,6 +50,19 @@ module Woods
50
50
  end
51
51
  end
52
52
 
53
+ # Lock actual managed targets, since user-scoped paths can be shared
54
+ # by different applications. Keep the receipt lock name for existing
55
+ # same-application callers; receipts and recovery journals stay separate.
56
+ def lock_paths
57
+ allowed_paths.map do |path|
58
+ path == receipt_path ? "#{path}.lock" : "#{path}.woods.lock"
59
+ end.sort
60
+ end
61
+
62
+ def runtime_paths
63
+ lock_paths + ["#{receipt_path}.pending"]
64
+ end
65
+
53
66
  def identity
54
67
  { 'client' => 'claude', 'scope' => scope, 'root' => root, 'config_dir' => config_dir,
55
68
  'config_path' => config_path, 'receipt_path' => receipt_path }