woods 2.0.0.beta4 → 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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +480 -477
  3. data/CONTRIBUTING.md +2 -2
  4. data/README.md +11 -26
  5. data/docs/AGENT_GUIDE.md +31 -12
  6. data/docs/AGENT_SETUP.md +17 -10
  7. data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
  8. data/docs/BACKEND_MATRIX.md +13 -7
  9. data/docs/CLIENT_HOOKS.md +1 -1
  10. data/docs/CONFIGURATION_REFERENCE.md +36 -26
  11. data/docs/CONSOLE_MCP_SETUP.md +10 -8
  12. data/docs/DOCKER_SETUP.md +15 -0
  13. data/docs/EVALUATION.md +10 -4
  14. data/docs/EXTRACTOR_REFERENCE.md +14 -2
  15. data/docs/FAQ.md +14 -3
  16. data/docs/GETTING_STARTED.md +18 -17
  17. data/docs/INCREMENTAL_EXTRACTION.md +8 -3
  18. data/docs/INDEX_LAYOUT.md +2 -2
  19. data/docs/MCP_SERVERS.md +28 -11
  20. data/docs/MCP_TOOL_COOKBOOK.md +1 -1
  21. data/docs/MCP_WORKTREE_SETUP.md +13 -1
  22. data/docs/PUBLISHED_INDEX.md +1 -1
  23. data/docs/README.md +2 -1
  24. data/docs/RETRIEVAL_GUIDE.md +57 -8
  25. data/docs/SOURCE_FRESHNESS.md +1 -1
  26. data/docs/TOKEN_BENCHMARK.md +16 -10
  27. data/docs/TROUBLESHOOTING.md +133 -37
  28. data/docs/UPGRADING_TO_2.md +9 -7
  29. data/docs/WATCH_DAEMON.md +172 -17
  30. data/docs/WHY_WOODS.md +9 -5
  31. data/exe/woods-console +13 -11
  32. data/exe/woods-watch +5 -0
  33. data/lib/generators/woods/watch_generator.rb +53 -0
  34. data/lib/puma/plugin/woods.rb +10 -0
  35. data/lib/tasks/woods.rake +14 -0
  36. data/lib/woods/cache/cache_middleware.rb +6 -0
  37. data/lib/woods/console/stdio_transport.rb +27 -0
  38. data/lib/woods/extractor.rb +25 -7
  39. data/lib/woods/git_command.rb +6 -7
  40. data/lib/woods/git_provenance.rb +4 -6
  41. data/lib/woods/mcp/bootstrapper.rb +3 -1
  42. data/lib/woods/mcp/initialization_guidance.rb +1 -1
  43. data/lib/woods/mcp/server.rb +41 -9
  44. data/lib/woods/retrieval/corpus_status.rb +46 -0
  45. data/lib/woods/retriever.rb +19 -7
  46. data/lib/woods/storage/local_corpus_stats.rb +32 -0
  47. data/lib/woods/storage/metadata_store.rb +20 -0
  48. data/lib/woods/storage/vector_store.rb +10 -0
  49. data/lib/woods/version.rb +1 -1
  50. data/lib/woods/watch/child_environment.rb +30 -0
  51. data/lib/woods/watch/cli.rb +91 -0
  52. data/lib/woods/watch/daemon.rb +55 -7
  53. data/lib/woods/watch/event_stream.rb +70 -0
  54. data/lib/woods/watch/guardian.rb +142 -0
  55. data/lib/woods/watch/installation/layout.rb +70 -0
  56. data/lib/woods/watch/installation/options.rb +128 -0
  57. data/lib/woods/watch/installation/planner.rb +128 -0
  58. data/lib/woods/watch/installation/probe.rb +101 -0
  59. data/lib/woods/watch/installation/receipt.rb +77 -0
  60. data/lib/woods/watch/installation/recovery.rb +64 -0
  61. data/lib/woods/watch/installation/templates.rb +58 -0
  62. data/lib/woods/watch/installation.rb +56 -0
  63. data/lib/woods/watch/lifecycle.rb +182 -0
  64. data/lib/woods/watch/managed_child.rb +113 -0
  65. data/lib/woods/watch/managed_cleanup.rb +48 -0
  66. data/lib/woods/watch/managed_process.rb +144 -0
  67. data/lib/woods/watch/puma_adapter.rb +87 -0
  68. data/lib/woods/watch/puma_child.rb +66 -0
  69. data/lib/woods/watch/supervision_records.rb +95 -0
  70. data/lib/woods/watch/supervision_status.rb +104 -0
  71. data/lib/woods/watch/supervisor.rb +161 -0
  72. data/lib/woods/watch/supervisor_reporting.rb +46 -0
  73. data/plugin/.claude-plugin/plugin.json +1 -1
  74. data/plugin/skills/woods-agent-enable/SKILL.md +1 -1
  75. data/plugin/skills/woods-diagnose/SKILL.md +77 -8
  76. data/plugin/skills/woods-investigate/SKILL.md +6 -6
  77. data/plugin/skills/woods-mcp-config/SKILL.md +28 -1
  78. data/plugin/skills/woods-setup/SKILL.md +58 -4
  79. metadata +35 -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
  ```
@@ -169,7 +314,7 @@ is alive, so *alive has to mean covered*.
169
314
  The standalone `woods:watch` task snapshots reload/restart inputs before invoking
170
315
  Rails' `environment` task. Inputs unchanged across that boundary, including
171
316
  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
317
+ Included in Woods `2.0.0`: registered restart inputs deleted while the
173
318
  daemon was stopped also trigger a full extraction after a fresh environment
174
319
  boot. Nominal framework paths still use the bounded deletion sweep.
175
320
  Changes during environment initialization still require restart. Lock contention,
@@ -283,6 +428,14 @@ Ignored by default: `.git`, `node_modules`, `tmp`, `log`, `coverage`,
283
428
  `vendor/bundle`, `public/assets`, `public/packs`, `storage`. That ignore list is
284
429
  what keeps a polling scan bounded.
285
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
+
286
439
  Polling and startup catch-up preserve each logical path when multiple directory
287
440
  symlinks point to the same source tree. For example, `a_shared/user.rb` and
288
441
  `app/models/user.rb` both remain visible; an earlier alias must not hide the path
@@ -293,15 +446,15 @@ aliases in large watched trees. Ignored logical paths are still pruned.
293
446
 
294
447
  ## Placement
295
448
 
296
- The spike asked for three placements to be compared and one chosen. Every
297
- collaborator on `Woods::Watch::Daemon` is injected, so all three are reachable
298
- from the same class, but the default is **(b), a dedicated daemon per
299
- 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:
300
453
 
301
454
  | Option | Verdict |
302
455
  |---|---|
303
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. |
304
- | **(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. |
305
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. |
306
459
 
307
460
  Measured on the fixture app (Ruby 3.3, Rails 8.0):
@@ -652,7 +805,7 @@ hooks (`plugin/hooks/hooks.json`), both shipped disabled:
652
805
  Both read `cwd` from the hook payload, so a linked worktree uses its own index.
653
806
  Both require an existing `generation.json` and `WOODS_HOOKS_ENABLED=1`;
654
807
  `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
808
+ **included in Woods `2.0.0`**. Check the installed gem's task list
656
809
  (`bundle exec rake -T woods:hook_refresh`, through the application container
657
810
  when appropriate) before enabling this plugin version. An older gem's unknown
658
811
  task error leaves queued events in place; installing the plugin does not upgrade
@@ -750,8 +903,10 @@ per-process language-server index does not.
750
903
 
751
904
  N resident daemons is N booted apps, and most slots are dormant most of the
752
905
  time. `idle_timeout` (off by default) stops a daemon after that many seconds
753
- without a file event, so a slot nobody is working in stops holding ~65 MB. A
754
- 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.
755
910
 
756
911
  ```ruby
757
912
  Woods::Watch::Daemon.new(output_dir: …, idle_timeout: 900).run # 15 minutes
@@ -813,8 +968,8 @@ supplies batches to it.
813
968
 
814
969
  ### Optional bounded context hints
815
970
 
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
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
818
973
  application bundle before enabling `WOODS_HOOK_CONTEXT_ENABLED=1`. The plugin
819
974
  version alone does not establish gem support. `WOODS_HOOKS_DISABLED=1` disables
820
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-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)
@@ -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)'
@@ -421,6 +421,12 @@ module Woods
421
421
  def mode = @retriever.respond_to?(:mode) ? @retriever.mode : :semantic
422
422
  def default_budget = @retriever.respond_to?(:default_budget) ? @retriever.default_budget : 8000
423
423
 
424
+ # Read the live corpus rather than caching diagnostics across reloads.
425
+ # @return [Hash, nil] local semantic corpus statistics, when supported
426
+ def corpus_status(include_types: true)
427
+ @retriever.corpus_status(include_types: include_types) if @retriever.respond_to?(:corpus_status)
428
+ end
429
+
424
430
  # Invalidate every cached context result. Called from the MCP +reload+
425
431
  # tool after the retriever's stores have been re-hydrated from a fresh
426
432
  # embed — otherwise cached results from the old embedding run would
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+
5
+ module Woods
6
+ module Console
7
+ # Keeps protocol writes separate from the host application's stdout.
8
+ # The SDK still owns framing, negotiation, notifications and shutdown.
9
+ class StdioTransport < ::MCP::Server::Transports::StdioTransport
10
+ # @param server [::MCP::Server] Console server
11
+ # @param output [IO] Original stdout saved before redirecting Rails output
12
+ def initialize(server, output:)
13
+ super(server)
14
+ @output = output
15
+ @output.set_encoding(Encoding::UTF_8)
16
+ end
17
+
18
+ # SDK responses, notifications and server requests share this writer.
19
+ # @param message [String, Hash] Encoded JSON or a JSON-compatible message
20
+ # @return [IO] Flushed protocol output
21
+ def send_response(message)
22
+ @output.puts(message.is_a?(String) ? message : JSON.generate(message))
23
+ @output.flush
24
+ end
25
+ end
26
+ end
27
+ end
@@ -2103,10 +2103,28 @@ module Woods
2103
2103
  end
2104
2104
 
2105
2105
  @git_available = complete_git_history?
2106
+ rescue Errno::ENOENT
2107
+ warn_missing_git_executable
2108
+ @git_available = false
2106
2109
  rescue StandardError
2107
2110
  @git_available = false
2108
2111
  end
2109
2112
 
2113
+ # An explicit Git directory still requests history when .git is not mounted
2114
+ # at Rails.root. Ordinary source archives remain a supported, quiet path.
2115
+ #
2116
+ # @return [void]
2117
+ def warn_missing_git_executable
2118
+ configured = [GitCommand::OVERRIDE_KEY, 'GIT_DIR'].any? { |key| !ENV[key].to_s.empty? }
2119
+ return unless configured || File.exist?(File.join(Rails.root.to_s, '.git'))
2120
+
2121
+ Rails.logger.warn(
2122
+ '[Woods] Git history unavailable: git executable was not found in PATH. ' \
2123
+ 'Extraction continues without per-unit Git metadata. Install Git in the extraction environment ' \
2124
+ 'and run a full extraction to refresh Git metadata.'
2125
+ )
2126
+ end
2127
+
2110
2128
  # A shallow HEAD resolves but represents an incomplete ancestry. Do not
2111
2129
  # turn that boundary into apparent one-commit/new-file churn facts.
2112
2130
  def complete_git_history?
@@ -2134,19 +2152,19 @@ module Woods
2134
2152
  cause = error.to_s.lines.first.to_s.strip
2135
2153
  Rails.logger.warn(
2136
2154
  '[Woods] git cannot resolve HEAD for this working tree, so no unit will carry git ' \
2137
- "metadata: #{cause}. Over a linked worktree in a container, mount the canonical git " \
2138
- 'directory and point WOODS_GIT_DIR at it; GIT_DIR alone is not enough, because the ' \
2139
- "worktree's private git directory reaches the shared one through a relative pointer."
2155
+ "metadata: #{cause}. For a linked worktree, mount the complete shared .git directory " \
2156
+ 'at its original path, or set WOODS_GIT_DIR to /mounted-common/worktrees/<id> within ' \
2157
+ 'the relocated complete layout. Derive <id> from the worktree Git metadata, not its branch name; ' \
2158
+ "selecting /mounted-common itself uses the primary checkout's HEAD."
2140
2159
  )
2141
2160
  end
2142
2161
 
2143
2162
  # The git command line every enrichment call runs.
2144
2163
  #
2145
2164
  # `-C <root>` keeps the result independent of the process working
2146
- # directory. `WOODS_GIT_DIR` wins when set: it names the canonical git
2147
- # directory directly, which is the escape hatch for a container that can
2148
- # mount that directory but not the host path a worktree pointer names
2149
- # (B-181).
2165
+ # directory. `WOODS_GIT_DIR` wins when set and selects that git directory's
2166
+ # HEAD. A relocated linked worktree needs its worktree-specific directory
2167
+ # inside the complete shared Git layout (B-181).
2150
2168
  #
2151
2169
  # @param args [Array<String>] git arguments
2152
2170
  # @return [Array<String>] full argv
@@ -7,18 +7,17 @@ module Woods
7
7
  # than at the process working directory, so extraction launched from another
8
8
  # checkout never reports that checkout's history.
9
9
  #
10
- # `WOODS_GIT_DIR` wins when set. It is the escape hatch for a container over
11
- # a linked worktree: `.git` there is a file naming an absolute host path that
12
- # may not be mounted, and git's own `GIT_DIR` is not enough, because a
13
- # worktree's private git directory reaches the shared object store through a
14
- # relative `commondir` pointer that resolves outside the mount, which
15
- # `GIT_COMMON_DIR` does not override (B-181, B-186).
10
+ # `WOODS_GIT_DIR` wins when set and selects HEAD exactly as `--git-dir`
11
+ # does. For a container over a linked worktree, select its private directory
12
+ # within the complete mounted Git layout (shared objects, refs and worktrees).
13
+ # Selecting the shared root instead selects the primary checkout's HEAD.
14
+ # A same-path mount that resolves the worktree's .git pointer needs no override.
16
15
  #
17
16
  # Three call sites use this, and the documentation promises all three:
18
17
  # per-unit enrichment (`Extractor`), manifest provenance (`GitProvenance`),
19
18
  # and the `woods:incremental` diff range (`lib/tasks/woods.rake`).
20
19
  module GitCommand
21
- # Environment variable naming the canonical git directory.
20
+ # Environment variable naming the explicitly selected git directory.
22
21
  OVERRIDE_KEY = 'WOODS_GIT_DIR'
23
22
 
24
23
  module_function
@@ -85,12 +85,10 @@ module Woods
85
85
  ''
86
86
  end
87
87
 
88
- # +WOODS_GIT_DIR+ names the canonical git directory outright and wins when
89
- # set. It is the escape hatch for a container that can mount that
90
- # directory but not the host path a linked worktree's +.git+ file points
91
- # at; git's own +GIT_DIR+ is not enough there, because a worktree's
92
- # private git directory reaches the shared one through a relative
93
- # +commondir+ pointer that resolves outside the mount.
88
+ # +WOODS_GIT_DIR+ selects a git directory and its HEAD outright and wins when
89
+ # set. A relocated linked worktree needs its private directory inside the
90
+ # complete mounted Git layout, preserving access to shared objects and refs.
91
+ # Selecting the shared root instead uses the primary checkout's HEAD.
94
92
  #
95
93
  # @param args [Array<String>] git arguments
96
94
  # @return [Array<String>] full argv
@@ -176,7 +176,9 @@ module Woods
176
176
  retriever = build_retriever_from_config(config, resolved, artifact, state)
177
177
  probe_and_mark_state(config, state)
178
178
  derive_state_from_store_health(state)
179
- warn "[woods-mcp] semantic search: #{state.status} (#{config.embedding_provider})"
179
+ corpus = retriever.corpus_status(include_types: false) if retriever.respond_to?(:corpus_status)
180
+ corpus_note = corpus ? "; corpus: #{corpus[:state]}" : ''
181
+ warn "[woods-mcp] semantic search: #{state.status} (#{config.embedding_provider})#{corpus_note}"
180
182
 
181
183
  [retriever, state]
182
184
  end
@@ -11,7 +11,7 @@ module Woods
11
11
 
12
12
  WORKFLOW = <<~TEXT
13
13
  Start with woods_status: check index readiness, generation freshness and relevant type counts before relying on results.
14
- For exact names, discover identifiers with search (prefer literal exact_prefix/exact_suffix), then inspect with lookup. For conceptual questions, use codebase_retrieve only when woods_status reports retrieval enabled. Otherwise use search and lookup.
14
+ For exact names, discover identifiers with search (prefer literal exact_prefix/exact_suffix), then inspect with lookup. For conceptual questions, check retrieval mode and data in woods_status before codebase_retrieve; structural readiness alone is insufficient. Otherwise use search and lookup.
15
15
  Follow dependencies or dependents at depth 1 or 2; narrow types and via before paging. Respect partial results and limits: a missing match is not proof of absence, and a partial traversal does not establish every dependent or leaf.
16
16
  Verify important conclusions against current source and tests. Recorded relationships and inferred downstream impact do not prove runtime execution or test coverage.
17
17
  Registration does not authorize extraction, configuration changes, or live Console access. Use only the tools registered here and operate within the user's authorized scope.