woods 1.4.1 → 1.6.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +208 -0
  3. data/CONTRIBUTING.md +63 -1
  4. data/README.md +35 -2
  5. data/lib/tasks/woods.rake +52 -2
  6. data/lib/woods/atomic_file.rb +43 -0
  7. data/lib/woods/cache/cache_middleware.rb +18 -1
  8. data/lib/woods/console/embedded_executor.rb +20 -2
  9. data/lib/woods/console/eval_guard.rb +8 -2
  10. data/lib/woods/console/sql_noise_stripper.rb +113 -0
  11. data/lib/woods/console/sql_table_scanner.rb +22 -9
  12. data/lib/woods/console/sql_validator.rb +1 -2
  13. data/lib/woods/coordination/pipeline_lock.rb +112 -7
  14. data/lib/woods/dependency_graph.rb +38 -1
  15. data/lib/woods/embedding/text_preparer.rb +6 -1
  16. data/lib/woods/export/unit_facts.rb +80 -0
  17. data/lib/woods/extractor.rb +152 -31
  18. data/lib/woods/extractors/concern_extractor.rb +3 -5
  19. data/lib/woods/extractors/configuration_extractor.rb +3 -5
  20. data/lib/woods/extractors/controller_extractor.rb +1 -1
  21. data/lib/woods/extractors/database_view_extractor.rb +1 -1
  22. data/lib/woods/extractors/decorator_extractor.rb +3 -5
  23. data/lib/woods/extractors/event_extractor.rb +2 -2
  24. data/lib/woods/extractors/factory_extractor.rb +2 -4
  25. data/lib/woods/extractors/graphql_extractor.rb +1 -1
  26. data/lib/woods/extractors/i18n_extractor.rb +6 -4
  27. data/lib/woods/extractors/job_extractor.rb +4 -6
  28. data/lib/woods/extractors/lib_extractor.rb +1 -1
  29. data/lib/woods/extractors/mailer_extractor.rb +1 -1
  30. data/lib/woods/extractors/manager_extractor.rb +3 -5
  31. data/lib/woods/extractors/migration_extractor.rb +1 -1
  32. data/lib/woods/extractors/model_extractor.rb +26 -10
  33. data/lib/woods/extractors/phlex_extractor.rb +1 -1
  34. data/lib/woods/extractors/policy_extractor.rb +3 -5
  35. data/lib/woods/extractors/poro_extractor.rb +1 -1
  36. data/lib/woods/extractors/pundit_extractor.rb +3 -5
  37. data/lib/woods/extractors/rake_task_extractor.rb +2 -4
  38. data/lib/woods/extractors/serializer_extractor.rb +4 -6
  39. data/lib/woods/extractors/service_extractor.rb +3 -5
  40. data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
  41. data/lib/woods/extractors/shared_utility_methods.rb +14 -0
  42. data/lib/woods/extractors/state_machine_extractor.rb +2 -4
  43. data/lib/woods/extractors/validator_extractor.rb +3 -5
  44. data/lib/woods/extractors/view_component_extractor.rb +1 -1
  45. data/lib/woods/filename_utils.rb +31 -2
  46. data/lib/woods/flow_assembler.rb +45 -28
  47. data/lib/woods/flow_precomputer.rb +2 -1
  48. data/lib/woods/git_provenance.rb +121 -0
  49. data/lib/woods/index_artifact.rb +8 -4
  50. data/lib/woods/mcp/bootstrapper.rb +6 -5
  51. data/lib/woods/mcp/config_resolver.rb +42 -21
  52. data/lib/woods/mcp/errors.rb +5 -3
  53. data/lib/woods/mcp/server.rb +68 -15
  54. data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
  55. data/lib/woods/model_name_cache.rb +6 -1
  56. data/lib/woods/notion/client.rb +4 -1
  57. data/lib/woods/notion/exporter.rb +7 -1
  58. data/lib/woods/obsidian/errors.rb +11 -0
  59. data/lib/woods/obsidian/name_mapper.rb +132 -0
  60. data/lib/woods/obsidian/note_builder.rb +260 -0
  61. data/lib/woods/obsidian/vault_assets.rb +104 -0
  62. data/lib/woods/obsidian/vault_exporter.rb +412 -0
  63. data/lib/woods/operator/error_escalator.rb +15 -4
  64. data/lib/woods/resilience/circuit_breaker.rb +98 -24
  65. data/lib/woods/resolved_config.rb +4 -1
  66. data/lib/woods/retrieval/ranker.rb +45 -7
  67. data/lib/woods/retry_after.rb +36 -0
  68. data/lib/woods/temporal/json_snapshot_store.rb +4 -1
  69. data/lib/woods/temporal/snapshot_store.rb +4 -1
  70. data/lib/woods/unblocked/client.rb +4 -1
  71. data/lib/woods/unblocked/document_builder.rb +21 -27
  72. data/lib/woods/update_check.rb +190 -0
  73. data/lib/woods/version.rb +1 -1
  74. data/lib/woods.rb +24 -0
  75. metadata +15 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b2da9b3b863eb794ca880de7a8b327c7edd22f5ea0b027bd705191af85ea755a
4
- data.tar.gz: 31c23f340816f84d3c1acc8e2cf09daa9bb7009179d1bd51cca51f7833c376e5
3
+ metadata.gz: f001f56232acc78a70a3b8584e1bb7dc7d94d9d0f6fa3d34a749bc963d2a361f
4
+ data.tar.gz: d9f5e7b0ff143d8d71e4368ce961e946cfc0a5b380793e6f4e190fedae968375
5
5
  SHA512:
6
- metadata.gz: 69f9bc1e0e83a7894ab0618b1044608f7eb3b869c7a881b04820d033a1a4c66bae7ce56be4c7bd858915679e941354af3f2907c2faa2decac1de8d0a4511913c
7
- data.tar.gz: 17360ebaf41923cb074d0b829b8940e24fbd3b7724243a0738fe73ee5a4fbcaf43108194d025a955073088dcf2ea0cd19380304191c41d9b325f02dcee43badd
6
+ metadata.gz: 5486c78c71a80e6c6445e149ff791dce250cf08503309ac0099e517ea680de3759da359a6e5a819963013f80d4ad3fe83b3f46b43e8d81d560cfaa4034a47552
7
+ data.tar.gz: 2af74e73f867a605a4ad5ca2770ea148583bd02c1e520ff87fb34bee0ca09be9dad2888875a37c97f3a620e877320dc32ee676e081c911f0c0ad6253bfe8604e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,214 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.0] - 2026-07-16
11
+
12
+ ### Added
13
+
14
+ - **MCP update awareness.** A new `Woods::UpdateCheck` module performs a best-effort,
15
+ 24h-cached RubyGems lookup for a newer `woods` release (disable with
16
+ `WOODS_NO_UPDATE_CHECK=1`). The Index Server's `woods_status` tool now reports
17
+ `server.update` (`current_version` / `latest_version` / `update_available`), and a call to a
18
+ tool the installed gem doesn't define now returns version-aware guidance ("not available in
19
+ the installed Woods vX — run `bundle update woods`") instead of a bare "Tool not found". This
20
+ keeps agents that follow a newer guide skill from silently failing against an older gem.
21
+ - **Claude Code plugin.** The three user-facing guide skills (`woods-setup`,
22
+ `woods-mcp-config`, `woods-diagnose`) are now packaged as the `woods-plugin`, distributed
23
+ from the [`lost-in-the/plugins`](https://github.com/lost-in-the/plugins) marketplace suite
24
+ via a `git-subdir` source (installs fetch only the `plugin/` subtree, not the whole gem).
25
+ Install with `/plugin marketplace add lost-in-the/plugins` then
26
+ `/plugin install woods-plugin@lost-in-the-plugins`. Each skill gained a **Version Preflight**
27
+ step so agents operate only against the installed Woods version (≥ 1.5.0) instead of
28
+ suggesting tools or tasks an older gem lacks.
29
+
30
+ ### Changed
31
+
32
+ - The user-facing guide skills moved from `docs/skills/` to `plugin/skills/` to form a valid
33
+ Claude Code plugin root (`plugin/.claude-plugin/plugin.json`). In-repo dev-workflow skills
34
+ under `.claude/skills/` are unaffected and remain undistributed.
35
+
36
+ ### Security
37
+
38
+ - **Console `sample`/`recent` tools now validate `columns` against the model's
39
+ real columns.** They passed the raw array to ActiveRecord's `.select`, which
40
+ treats string args as SQL fragments — a crafted column could smuggle a
41
+ subquery into the SELECT list, bypassing TableGate and column redaction
42
+ (`pluck` already validated; `sample`/`recent` didn't).
43
+ - **`trace_flow` sanitizes the entry point before building the flow file path.**
44
+ Only `::`→`__` was applied, so `/` and `..` in a client-supplied entry point
45
+ could traverse outside the `flows/` directory (a file-read oracle over the
46
+ HTTP transport). Both path segments now go through the `FilenameUtils`
47
+ allow-list.
48
+ - **TableGate's SQL table detector no longer misses tables on PostgreSQL.** It
49
+ hardcoded MySQL literal-stripping, so on PostgreSQL (`standard_conforming_strings`)
50
+ the MySQL `\'` rule could over-strip and swallow a real `FROM <blocked>`
51
+ clause. It now strips under both dialects and unions the identifiers.
52
+ - **TableGate no longer misses a table hidden behind a comment marker inside a
53
+ string literal.** `SqlNoiseStripper` stripped comments *before* literals, so
54
+ `SELECT '-- ' FROM blocked` had its real `FROM blocked` swallowed as a line
55
+ comment and slipped past the gate. Comment- and literal-stripping now run in a
56
+ single combined left-to-right pass (`SqlNoiseStripper.strip_noise`) — a
57
+ comment marker inside a literal, and a quote inside a comment, are each
58
+ protected by whichever opens first. `SqlValidator` and the scope-template
59
+ guard route through the same pass.
60
+ - **`EvalGuard` rejects `%x` shell literals with any delimiter — including
61
+ whitespace.** The delimiter check excluded `\s`, but a newline is a valid `%x`
62
+ delimiter (`%x\ncmd\n` compiles and executes), so it slipped through to
63
+ `instance_eval`. The check now matches any non-word delimiter (`[^\w]`).
64
+ - **`PipelineLock` no longer allows two processes to hold the lock.** Both the
65
+ stale-lock takeover and the release path had TOCTOU windows: a process that
66
+ passed the staleness check could rename away — or a plain read-then-unlink
67
+ release could delete — a competitor's *fresh* lock, so both would "hold" it.
68
+ Takeover now re-verifies the retired file is still stale (restoring it and
69
+ backing off if a competitor already took over), and release renames the file
70
+ aside and re-checks its ownership token before deleting.
71
+
72
+ ### Fixed
73
+
74
+ - **Incremental extraction no longer corrupts the index.** `rake woods:incremental`
75
+ overwrote `manifest.json` with zero counts, captured an empty temporal snapshot
76
+ (making the diff report every unit as deleted), wrote absolute file paths into
77
+ the index, and re-indexed affected units with a null `estimated_tokens`.
78
+ - **Retrieval ranking signals work on real backends again.** Recency, importance,
79
+ type-match, and diversity read metadata with symbol keys, but every store
80
+ returns string keys — so all four scored every unit at their neutral fallback
81
+ in production. The ranker now reads both key forms.
82
+ - **The embedding cache is now scoped to the provider model.** The cache key was
83
+ `SHA256(text)` with no model, so a persistent shared backend served the
84
+ previous model's vector after a model switch. `model_name` is now part of the
85
+ key — a plain attribute rather than `dimensions`, whose Ollama implementation
86
+ is a live `embed('test')` probe (keying on it made every cache lookup, hits
87
+ included, depend on the provider being reachable, and defeated the cache while
88
+ the backend was down).
89
+ - **`callback_count` reports real counts.** It called the nonexistent
90
+ `CallbackChain#size`, which raised and left the count silently at 0 for every
91
+ model; switched to `#count`.
92
+ - **`ResolvedConfig` is now deeply immutable.** Nested strings in
93
+ `embedding_provider`/`stores` stayed mutable, so the frozen snapshot could be
94
+ corrupted in place.
95
+ - **Model dependency edges are correct for nested and re-registered units.** The
96
+ `ModelNameCache` alternation matched a prefixed parent (`Library::Book` for
97
+ `Library::Book::Chapter`); `DependencyGraph#register` left stale reverse edges
98
+ on re-registration, inflating incremental blast radius.
99
+ - **Precomputed request flows are no longer empty/stale.** `FlowPrecomputer` ran
100
+ before unit JSON was written to disk; it now runs after `write_results`.
101
+ - **`trace_flow` resolves precomputed flows again.** The reader allow-listed the
102
+ entry point but the writer left the action name raw, so a flow for an action
103
+ outside `[A-Za-z0-9_-]` was written under one filename and looked up under
104
+ another (silently falling back to live assembly). Both sides now derive the
105
+ filename from the shared `FilenameUtils.flow_filename`.
106
+ - **Flow annotation no longer resurrects deleted units.** On a full extraction
107
+ with `precompute_flows`, refreshing a type's `_index.json` rebuilt it from a
108
+ disk glob of the never-wiped output dir, re-adding unit files for classes
109
+ deleted from the app since the last run. The index is now rebuilt from the
110
+ in-memory `@results` (the incremental path, which only holds changed units,
111
+ still rebuilds from disk).
112
+ - **`FlowAssembler` no longer reports DAG diamonds as cycles**, and
113
+ `IndexArtifact#promote` no longer accepts sibling directories that merely
114
+ share a name prefix with `dumps/`.
115
+ - **`implicit_belongs_to` metadata is accurate.** It was flagged on every
116
+ ActiveRecord presence validator; it now keys on `belongs_to` reflections.
117
+ - **Dependency scanning ignores commented references consistently** across all
118
+ three passes (a commented `Library::Book` still produced a ghost edge), while
119
+ keeping references that follow a `#` *inside a string literal* (`link_to "Tag
120
+ #ruby", Article.recent`). Comment-stripping is string-literal-aware — a plain
121
+ `#…` regex ate the rest of such lines and dropped the real edge.
122
+ - **`pipeline_extract(incremental: true)` requires `changed_files`** instead of
123
+ silently re-extracting nothing while reporting success.
124
+ - **`pipeline_diagnose` classifies by the supplied error class** (it built a bare
125
+ `StandardError`, so every `Timeout`/`Net::`/`Errno` error came back
126
+ `:unknown`).
127
+ - **`notion_sync` honors the `NOTION_API_TOKEN` env var**, matching the gate that
128
+ registers the tool (ENV-only hosts previously saw the tool but every call
129
+ failed). A *blank* env var (docker-compose `${NOTION_API_TOKEN}` interpolation
130
+ of an unset host variable) is now treated as absent rather than masking a
131
+ configured token or passing through as a blank bearer. All four resolution
132
+ sites (exporter, `notion_wired?` gate, tool handler, rake task) share
133
+ `Woods.resolve_notion_token`.
134
+ - **Embedded documents include the `dependencies:` line again** — the indexer
135
+ leaves dependency hashes string-keyed and the text preparer only read symbol
136
+ keys.
137
+ - The MCP CLI integration spec no longer fails under a POSIX/C locale (UTF-8
138
+ subprocess output is normalized before regex matching).
139
+ - **`CircuitBreaker` admits only a single probe in `half_open`.** It let every
140
+ concurrent call through while half-open (a thundering herd against a
141
+ recovering service), and a slow probe's success could wipe failures recorded
142
+ by an overlapping probe. Concurrent probes are now rejected with
143
+ `CircuitOpenError`, and an optional `success_threshold` requires N consecutive
144
+ successful probes to close (default 1). Recovery accounting is also keyed to
145
+ the per-call probe flag, not the shared state: a stale call admitted while the
146
+ circuit was *closed* that completes after the transition to `half_open` no
147
+ longer counts as the probe (it could otherwise close the circuit — or clear a
148
+ concurrent probe's slot — while the service was still down).
149
+ - **`Retry-After` honors the HTTP-date form.** The Notion and Unblocked clients
150
+ parsed the header with `.to_f`, turning an HTTP-date into `0.0` and retrying
151
+ immediately against a throttling server. A shared `Woods::RetryAfter` helper
152
+ now handles both the delta-seconds and HTTP-date forms.
153
+ - **The MCP pipeline lock is no longer racy under the HTTP transport.** The
154
+ `@pipeline_mutex ||= Mutex.new` lazy init let two concurrent handlers create
155
+ separate mutexes and run two pipelines of the same kind; the mutex is now
156
+ eagerly initialized.
157
+
158
+ ### Changed
159
+
160
+ - Directory globbing and dependency deduplication across the extractor fleet are
161
+ centralized in `SharedUtilityMethods#find_files_in_directories` and
162
+ `SharedDependencyScanner#consolidate_dependencies` (behavior-preserving).
163
+
164
+ ### Documentation
165
+
166
+ - Added an in-container Index Server section to `DOCKER_SETUP.md` (#139),
167
+ corrected stale tool counts, corrected backend config examples that referenced
168
+ nonexistent adapters (#83), and listed the previously-undocumented rake tasks.
169
+
170
+ ## [1.5.0] - 2026-06-23
171
+
172
+ ### Added
173
+
174
+ - **Rails 6.0 support: the `railties` floor is lowered from `>= 6.1` to `>= 6.0`**
175
+ (#135). Woods runs cleanly on the Rails 6.0 series — extraction and index-MCP
176
+ serving need no 6.1-only API. The only 6.1-introduced calls touched
177
+ (`connection_db_config`, `has_many_inversing`) are `respond_to?`-guarded and
178
+ degrade on 6.0; a regression spec locks that in.
179
+ - **CI now runs a Rails version matrix plus a booted-app extraction test** (#136).
180
+ `Appraisals` + `gemfiles/rails_*.gemfile` exercise Rails 6.0, 6.1, 7.0, 7.1,
181
+ 7.2, and 8.0; the matrix excludes invalid Ruby×Rails pairs (e.g. Rails 6.0 only
182
+ on Ruby 3.0) and adds a **Ruby 4.0** lane. A new booted-app test
183
+ (`spec/integration/booted_extraction_spec.rb`, against the minimal `spec/dummy`
184
+ app) boots Rails in-process and runs a real end-to-end extraction, asserting a
185
+ non-zero unit count and the expected models/associations — gating the
186
+ version-sensitive introspection path the unit suite (which stubs Rails) can't.
187
+
188
+ ### Changed
189
+
190
+ - **The Index Server now boots in pattern-only mode by default when no embedding
191
+ index is present** (#138). Since 1.3.0, `woods-mcp` raised `MissingArtifact` at
192
+ boot unless `woods.json` existed or `WOODS_ALLOW_AUTODETECT=1` was set — which
193
+ surprised the most common setup: `rake woods:extract` with no embedding
194
+ provider, where pattern/regex/structural search works perfectly. The server now
195
+ auto-detects by default: it serves all always-on tools (`lookup`, `search`,
196
+ `dependencies`, `structure`, `graph_analysis`, `pagerank`, …) with no env var,
197
+ and `codebase_retrieve` (semantic search) activates automatically once an
198
+ embedding provider is configured. Set `WOODS_REQUIRE_INDEX=1` to restore
199
+ fail-closed behavior (raise `MissingArtifact` when `woods.json` is absent).
200
+ `WOODS_ALLOW_AUTODETECT` is retained as a no-op for backward compatibility.
201
+
202
+ ### Fixed
203
+
204
+ - **`manifest.json` no longer reports a stale `git_branch`/`git_sha` in a git
205
+ worktree** (#137). In a linked worktree, `.git` is a file pointing at the real
206
+ git directory (often an absolute host path). When that directory couldn't be
207
+ resolved — e.g. inside a container where the host path isn't mounted — git
208
+ enrichment failed silently and the branch/SHA fell back to a baked
209
+ `GIT_BRANCH`/`GIT_SHA` build arg, reporting an unrelated branch. A new
210
+ `Woods::GitProvenance` resolves provenance with worktree-aware plumbing
211
+ (`git -C <root> rev-parse`), and emits `"unknown"` when a `.git` is present but
212
+ the ref can't be resolved (the worktree case) rather than a misleading value.
213
+ The `GIT_BRANCH`/`GIT_SHA` env vars are honored only when there is **no** `.git`
214
+ at the root at all (a non-repo checkout — e.g. a Docker `COPY` that excludes
215
+ `.git` — with build args supplying the SHA) or git is unavailable. Temporal
216
+ snapshots skip an `"unknown"` SHA so it can't key or collide a snapshot.
217
+
10
218
  ## [1.4.1] - 2026-06-10
11
219
 
12
220
  ### Fixed
data/CONTRIBUTING.md CHANGED
@@ -28,7 +28,36 @@ Open an issue describing:
28
28
  5. Ensure the test suite passes: `bundle exec rake spec`
29
29
  6. Ensure code style passes: `bundle exec rubocop`
30
30
  7. Update CHANGELOG.md with your changes
31
- 8. Open a pull request
31
+ 8. Complete the **Pre-PR requirements** below
32
+ 9. Open a pull request
33
+
34
+ ### Pre-PR requirements
35
+
36
+ These are hard gates — a PR that fails either is incomplete:
37
+
38
+ 1. **Documentation must be current.** Any doc affected by the change — README, `docs/`, the
39
+ `plugin/skills/` user guides, `CHANGELOG.md` — must be updated in the *same* PR. Don't ship
40
+ behavior the docs still describe the old way.
41
+ 2. **Investigate plugin-functionality impact.** If the change touches anything the distributed
42
+ user skills rely on — a rake task, MCP tool or its arguments, an executable (`woods-mcp`,
43
+ `woods-mcp-start`, `woods-console-mcp`, `woods-mcp-http`), a config key, or setup steps —
44
+ investigate whether `plugin/skills/{woods-setup,woods-mcp-config,woods-diagnose}` need to
45
+ change.
46
+
47
+ ### Claude Code plugin changes
48
+
49
+ `plugin/` is distributed as the `woods-plugin` via the
50
+ [`lost-in-the/plugins`](https://github.com/lost-in-the/plugins) marketplace (a `git-subdir`
51
+ reference to this subtree). Installed users may run an **older** gem than `main`, so:
52
+
53
+ - If a change adds/removes/renames a tool, task, executable, or config key that a skill
54
+ documents, **update the skill in the same PR**.
55
+ - The skills carry a Version Preflight (operate only against the installed version). **Land the
56
+ skill change with the release that ships the capability** — never document a feature in a
57
+ skill before the version that provides it is released. Bump `plugin/.claude-plugin/plugin.json`
58
+ `version` when the skill content changes.
59
+ - If the change requires a new marketplace entry, `ref` pin, or metadata edit, open a **paired
60
+ PR against `lost-in-the/plugins`** and link it from this PR.
32
61
 
33
62
  ## Development Setup
34
63
 
@@ -49,6 +78,39 @@ Woods has two test suites:
49
78
 
50
79
  All new features need tests. Bug fixes should include a regression test.
51
80
 
81
+ ### Rails version matrix
82
+
83
+ The gem supports `railties >= 6.0`. Coverage is split across two CI jobs:
84
+
85
+ - The base unit `test` job runs `rake spec` across **Ruby 3.0–4.0** on the
86
+ default (newest) Rails. The unit specs stub Rails, so they run once on the base
87
+ Gemfile rather than per Rails version.
88
+ - The `rails-matrix` job runs the **booted-app extraction test**
89
+ (`spec/integration/booted_extraction_spec.rb` against `spec/dummy`) under each
90
+ supported Rails — 6.0, 6.1, 7.0, 7.1, 7.2, 8.0 — using per-version gemfiles
91
+ under `gemfiles/`. This is the version-sensitive gate: it boots a real Rails
92
+ app in-process and runs an extraction. (The booted spec is tagged `:booted_app`
93
+ and excluded from the default `rake spec`; `WOODS_RUN_BOOTED_APP=1` opts it in,
94
+ and it must run in its own process — it can't share one with the unit suite.)
95
+
96
+ The Rails pins live in `Appraisals`; the gemfiles are hand-maintained
97
+ (`eval_gemfile`-ing the base `Gemfile` and pinning Rails) because Appraisal can't
98
+ generate from the conditional base Gemfile. To run a single Rails row locally:
99
+
100
+ ```bash
101
+ BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle install
102
+ WOODS_RUN_BOOTED_APP=1 BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile \
103
+ bundle exec rspec spec/integration/booted_extraction_spec.rb
104
+ ```
105
+
106
+ When adding a Rails line: add it to both `Appraisals` and `gemfiles/`, and add a
107
+ valid Ruby×Rails pair to the `rails-matrix` job in `.github/workflows/ci.yml`.
108
+ **For a row below Rails 7.1**, the gemfile must set `ENV['WOODS_SQLITE3_REQ'] =
109
+ '~> 1.4'` *before* `eval_gemfile` (those Rails versions pin `sqlite3 ~> 1.4` in
110
+ their adapter at load time) and pin `concurrent-ruby '< 1.3.5'` (1.3.5 dropped
111
+ the implicit `require "logger"` those releases rely on under Ruby 3.x) — copy an
112
+ existing `gemfiles/rails_6.0.gemfile` as the template.
113
+
52
114
  ## Code Style
53
115
 
54
116
  - `frozen_string_literal: true` on every file
data/README.md CHANGED
@@ -249,6 +249,31 @@ Static tools miss `status_active?`, `status_pending?`, `build_line_item`, `creat
249
249
 
250
250
  ---
251
251
 
252
+ ## Claude Code plugin
253
+
254
+ Woods publishes three **user-facing guide skills** as a Claude Code plugin so agents can walk
255
+ you through setup, MCP configuration, and troubleshooting without leaving your editor:
256
+
257
+ - `woods-setup` — install, configure, extract, verify, connect MCP servers
258
+ - `woods-mcp-config` — generate a correct `.mcp.json` for your environment
259
+ - `woods-diagnose` — systematic troubleshooting for extraction/MCP/embedding/storage
260
+
261
+ The plugin ships from the [`lost-in-the/plugins`](https://github.com/lost-in-the/plugins)
262
+ marketplace suite, which references this repo's `plugin/` subtree via a `git-subdir` source —
263
+ so installing fetches only the skill files, not the whole gem:
264
+
265
+ ```bash
266
+ # In Claude Code:
267
+ /plugin marketplace add lost-in-the/plugins
268
+ /plugin install woods-plugin@lost-in-the-plugins
269
+ ```
270
+
271
+ The skill files live in this repo under [`plugin/skills/`](plugin/skills/) (the plugin root is
272
+ [`plugin/`](plugin/), with its manifest at `plugin/.claude-plugin/plugin.json`). Each skill
273
+ opens with a Version Preflight so agents operate only against your installed Woods version. To
274
+ test a local checkout: `claude --plugin-dir /path/to/woods/plugin`. Requires **Woods ≥ 1.5.0**.
275
+ The internal dev-workflow skills under `.claude/skills/` are **not** part of this plugin.
276
+
252
277
  ## Connect to Your AI Tool
253
278
 
254
279
  Woods ships two MCP servers. Most users only need the **Index Server**.
@@ -448,8 +473,14 @@ rake woods:clean # Remove index output (alias: woods:clear)
448
473
  rake woods:embed # Embed units for semantic search (alias: woods:nest)
449
474
  rake woods:embed_incremental # Embed changed units only (alias: woods:hone)
450
475
  rake woods:notion_sync # Sync models/columns to Notion (alias: woods:send)
476
+ rake woods:obsidian # Export to an Obsidian vault — graph view + Bases (alias: woods:vault)
451
477
  ```
452
478
 
479
+ > **Visualize connections in Obsidian.** `woods:obsidian` renders the codebase as a self-contained
480
+ > [Obsidian](https://obsidian.md) vault: one interlinked note per unit (explore the dependency graph
481
+ > in graph view), a filterable [Bases](https://help.obsidian.md/bases) table, and a `_woods/` machine
482
+ > sidecar so agents can load the whole topology in one read. See [Obsidian Integration](docs/OBSIDIAN_INTEGRATION.md).
483
+
453
484
  ---
454
485
 
455
486
  ## How It Works Under the Hood
@@ -573,8 +604,10 @@ See [Architecture](docs/ARCHITECTURE.md) for the deep dive — extraction phases
573
604
 
574
605
  ## Requirements
575
606
 
576
- - Ruby >= 3.0
577
- - Rails >= 6.1
607
+ - Ruby >= 3.0 (through Ruby 4.0)
608
+ - Rails >= 6.0
609
+
610
+ CI exercises Rails 6.0, 6.1, 7.0, 7.1, 7.2, and 8.0 across Ruby 3.0–4.0 — see the supported-version table in [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md).
578
611
 
579
612
  Works with MySQL, PostgreSQL, and SQLite. No additional infrastructure required for basic extraction — embedding and vector search are optional add-ons.
580
613
 
data/lib/tasks/woods.rake CHANGED
@@ -534,8 +534,10 @@ namespace :woods do
534
534
  require 'woods/notion/exporter'
535
535
 
536
536
  config = Woods.configuration
537
- # Env var takes precedence over configured value
538
- config.notion_api_token = ENV.fetch('NOTION_API_TOKEN', nil) || config.notion_api_token
537
+ # A non-blank env var takes precedence over the configured value; a blank
538
+ # NOTION_API_TOKEN is treated as absent. Shared resolution keeps the rake
539
+ # task, the exporter, and the MCP tool consistent.
540
+ config.notion_api_token = Woods.resolve_notion_token(config)
539
541
 
540
542
  unless config.notion_api_token
541
543
  puts 'ERROR: Notion API token not configured.'
@@ -650,6 +652,54 @@ namespace :woods do
650
652
  desc 'Relay findings to Unblocked (alias for unblocked_sync)'
651
653
  task relay: :unblocked_sync
652
654
 
655
+ desc 'Export extraction data to a self-contained Obsidian vault'
656
+ task obsidian: :environment do
657
+ require 'woods/obsidian/vault_exporter'
658
+
659
+ config = Woods.configuration
660
+ output_dir = ENV.fetch('WOODS_OUTPUT', config.output_dir)
661
+ vault_path = ENV.fetch('WOODS_OBSIDIAN_VAULT', File.join(output_dir.to_s, 'obsidian_vault'))
662
+ # Truthy set, so FLAG=false / FLAG=0 disables rather than silently enabling.
663
+ env_flag = ->(name) { %w[1 true yes].include?(ENV.fetch(name, '').strip.downcase) }
664
+
665
+ puts 'Exporting extraction data to an Obsidian vault...'
666
+ puts " Output dir: #{output_dir}"
667
+ puts " Vault: #{vault_path}"
668
+ puts
669
+
670
+ begin
671
+ exporter = Woods::Obsidian::VaultExporter.new(
672
+ index_dir: output_dir,
673
+ vault_path: vault_path,
674
+ include_source: env_flag.call('WOODS_OBSIDIAN_INCLUDE_SOURCE'),
675
+ include_framework: env_flag.call('WOODS_OBSIDIAN_INCLUDE_FRAMEWORK'),
676
+ force_purge: env_flag.call('WOODS_OBSIDIAN_FORCE_PURGE')
677
+ )
678
+ stats = exporter.export_all
679
+ rescue Woods::Obsidian::ExportError => e
680
+ puts "ERROR: #{e.message}"
681
+ exit 1
682
+ end
683
+
684
+ puts 'Export complete!'
685
+ puts " Notes: #{stats[:exported]}"
686
+ puts " Indexes: #{stats[:indexes]}"
687
+ puts " Swept: #{stats[:swept]}"
688
+ puts " Skipped: #{stats[:skipped]}"
689
+ puts
690
+ puts "Open it in Obsidian: 'Open folder as vault' -> #{vault_path}"
691
+
692
+ if stats[:errors].any?
693
+ puts " Errors: #{stats[:errors].size}"
694
+ stats[:errors].first(5).each { |e| puts " - #{e}" }
695
+ puts " ... and #{stats[:errors].size - 5} more" if stats[:errors].size > 5
696
+ exit 1
697
+ end
698
+ end
699
+
700
+ desc 'Render the codebase as an Obsidian vault (alias for obsidian)'
701
+ task vault: :obsidian
702
+
653
703
  desc 'Generate a random bearer token for woods-mcp-http (WOODS_MCP_HTTP_TOKEN)'
654
704
  task :generate_token do
655
705
  require 'securerandom'
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+
6
+ module Woods
7
+ # Crash-safe file writes via a temp file + atomic rename.
8
+ #
9
+ # Extracted from the pattern in {Woods::IndexArtifact} (whose own
10
+ # +atomic_write+ is private and instance-level) so any component that writes
11
+ # many files — e.g. the Obsidian exporter writing a vault of notes — can reuse
12
+ # it. If the process dies mid-write, the destination file is either the old
13
+ # content or the new content, never a torn partial.
14
+ #
15
+ # @example
16
+ # Woods::AtomicFile.write("tmp/woods/obsidian_vault/models/User.md", note_body)
17
+ module AtomicFile
18
+ module_function
19
+
20
+ # Atomically write +content+ to +path+, creating parent directories.
21
+ #
22
+ # @param path [String, Pathname] destination path
23
+ # @param content [String] file content
24
+ # @return [void]
25
+ def write(path, content)
26
+ path = path.to_s
27
+ FileUtils.mkdir_p(File.dirname(path))
28
+ tmp = Tempfile.new('.woods-', File.dirname(path))
29
+ # Binary mode so the content's bytes (e.g. UTF-8) are written verbatim
30
+ # regardless of the process's default external encoding.
31
+ tmp.binmode
32
+ tmp.write(content)
33
+ tmp.flush
34
+ tmp.fsync
35
+ tmp.close
36
+ File.rename(tmp.path, path)
37
+ rescue StandardError
38
+ tmp&.close
39
+ tmp&.unlink
40
+ raise
41
+ end
42
+ end
43
+ end
@@ -359,10 +359,27 @@ module Woods
359
359
 
360
360
  # Build a cache key for an embedding text.
361
361
  #
362
+ # The provider's model_name is folded into the key: a cached embedding
363
+ # is only valid for the exact model that produced it. Without this, a
364
+ # persistent shared backend (Redis/SolidCache) returns the previous
365
+ # model's vector after a model switch or upgrade — different dimensions
366
+ # error mid-batch, same dimensions silently corrupt similarity scores
367
+ # (which IndexValidator can't detect, since it checks provider-vs-store
368
+ # dims, not cache contents).
369
+ #
370
+ # model_name (a plain attribute) is used rather than dimensions on
371
+ # purpose: for every supported provider the model uniquely determines
372
+ # the vector dimensionality, and `Provider#dimensions` can force a live
373
+ # network probe (Ollama memoizes `embed('test').length`; OpenAI probes
374
+ # for unknown models). Keying on dimensions made every cache lookup —
375
+ # including hits — depend on the provider being reachable, defeating the
376
+ # cache exactly when the backend is down. model_name distinguishes
377
+ # models without any I/O.
378
+ #
362
379
  # @param text [String]
363
380
  # @return [String]
364
381
  def embedding_key(text)
365
- Cache.cache_key(:embeddings, Digest::SHA256.hexdigest(text))
382
+ Cache.cache_key(:embeddings, @provider.model_name.to_s, Digest::SHA256.hexdigest(text))
366
383
  end
367
384
  end
368
385
 
@@ -458,6 +458,7 @@ module Woods
458
458
  end
459
459
 
460
460
  def handle_sample(params)
461
+ validate_select_columns!(params)
461
462
  model = resolve_model(params['model'])
462
463
  limit = [params.fetch('limit', 5).to_i, 25].min
463
464
  scope = apply_scope(model, params['scope'], model_name: params['model'])
@@ -563,6 +564,7 @@ module Woods
563
564
  end
564
565
 
565
566
  def handle_recent(params)
567
+ validate_select_columns!(params)
566
568
  model = resolve_model(params['model'])
567
569
  order_by = params.fetch('order_by', 'created_at')
568
570
  direction = params.fetch('direction', 'desc')
@@ -903,8 +905,10 @@ module Woods
903
905
  # instead of a confusing adapter-level syntax failure. It also
904
906
  # neutralises `--` line comments and PostgreSQL dollar-quoted
905
907
  # strings that could carry forbidden keywords past a naive scan.
906
- # `SqlNoiseStripper` is the same module SqlValidator uses.
907
- stripped = SqlNoiseStripper.strip_literals(SqlNoiseStripper.strip_comments(template))
908
+ # `SqlNoiseStripper` is the same module SqlValidator uses. The
909
+ # combined single-pass strip_noise resolves comments and literals
910
+ # together so a comment marker inside a literal can't hide a keyword.
911
+ stripped = SqlNoiseStripper.strip_noise(template)
908
912
  if SCOPE_TEMPLATE_FORBIDDEN.match?(stripped)
909
913
  raise ValidationError,
910
914
  'scope template contains forbidden SQL keywords ' \
@@ -928,6 +932,20 @@ module Woods
928
932
  scope.any? { |k, _| ScopePredicateParser::SUFFIX_PATTERN.match?(k.to_s) }
929
933
  end
930
934
 
935
+ # Validate that any requested +columns+ are real model columns before
936
+ # they reach +apply_columns+. ActiveRecord treats string args to
937
+ # +.select+ as raw SQL fragments, so an unvalidated column smuggles a
938
+ # subquery into the SELECT list (the sample/recent tools skip
939
+ # +check_sql!+, so this is their only gate). Mirrors +handle_pluck+.
940
+ #
941
+ # @param params [Hash] Tool params (uses 'model' and 'columns')
942
+ # @raise [ArgumentError] if any column is not declared on the model
943
+ def validate_select_columns!(params)
944
+ return unless params['columns']
945
+
946
+ @model_validator.validate_columns!(params['model'], params['columns'])
947
+ end
948
+
931
949
  # Apply column selection to a relation.
932
950
  #
933
951
  # @param relation [ActiveRecord::Relation] The relation
@@ -194,8 +194,14 @@ module Woods
194
194
  # `%x{cmd}`) — the AST flavor of these is `:xstr`/`:xstr_heredoc`,
195
195
  # which {Woods::Ast::Parser} may normalize differently across
196
196
  # Prism/parser-gem backends. A source-level refusal is both cheap
197
- # and impossible to evade via AST normalization.
198
- if code.include?('`') || code =~ /%x[{<|!@#(\[]/
197
+ # and impossible to evade via AST normalization. Ruby accepts ANY
198
+ # non-word character as the %x delimiter — including whitespace:
199
+ # `%x\ncmd\n` (newline-delimited) is valid and executes. Match
200
+ # every non-word char (`[^\w]`, which spans newline/space/tab, not
201
+ # `[^\w\s]` which excluded them and let `%x\n…` slip through). As a
202
+ # fail-safe, over-refusing odd-but-harmless source beats letting an
203
+ # unlisted delimiter through.
204
+ if code.include?('`') || code =~ /%x[^\w]/
199
205
  raise ForbiddenExpressionError, 'payload contains a shell-execution literal (backtick or %x)'
200
206
  end
201
207