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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +208 -0
- data/CONTRIBUTING.md +63 -1
- data/README.md +35 -2
- data/lib/tasks/woods.rake +52 -2
- data/lib/woods/atomic_file.rb +43 -0
- data/lib/woods/cache/cache_middleware.rb +18 -1
- data/lib/woods/console/embedded_executor.rb +20 -2
- data/lib/woods/console/eval_guard.rb +8 -2
- data/lib/woods/console/sql_noise_stripper.rb +113 -0
- data/lib/woods/console/sql_table_scanner.rb +22 -9
- data/lib/woods/console/sql_validator.rb +1 -2
- data/lib/woods/coordination/pipeline_lock.rb +112 -7
- data/lib/woods/dependency_graph.rb +38 -1
- data/lib/woods/embedding/text_preparer.rb +6 -1
- data/lib/woods/export/unit_facts.rb +80 -0
- data/lib/woods/extractor.rb +152 -31
- data/lib/woods/extractors/concern_extractor.rb +3 -5
- data/lib/woods/extractors/configuration_extractor.rb +3 -5
- data/lib/woods/extractors/controller_extractor.rb +1 -1
- data/lib/woods/extractors/database_view_extractor.rb +1 -1
- data/lib/woods/extractors/decorator_extractor.rb +3 -5
- data/lib/woods/extractors/event_extractor.rb +2 -2
- data/lib/woods/extractors/factory_extractor.rb +2 -4
- data/lib/woods/extractors/graphql_extractor.rb +1 -1
- data/lib/woods/extractors/i18n_extractor.rb +6 -4
- data/lib/woods/extractors/job_extractor.rb +4 -6
- data/lib/woods/extractors/lib_extractor.rb +1 -1
- data/lib/woods/extractors/mailer_extractor.rb +1 -1
- data/lib/woods/extractors/manager_extractor.rb +3 -5
- data/lib/woods/extractors/migration_extractor.rb +1 -1
- data/lib/woods/extractors/model_extractor.rb +26 -10
- data/lib/woods/extractors/phlex_extractor.rb +1 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -5
- data/lib/woods/extractors/poro_extractor.rb +1 -1
- data/lib/woods/extractors/pundit_extractor.rb +3 -5
- data/lib/woods/extractors/rake_task_extractor.rb +2 -4
- data/lib/woods/extractors/serializer_extractor.rb +4 -6
- data/lib/woods/extractors/service_extractor.rb +3 -5
- data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
- data/lib/woods/extractors/shared_utility_methods.rb +14 -0
- data/lib/woods/extractors/state_machine_extractor.rb +2 -4
- data/lib/woods/extractors/validator_extractor.rb +3 -5
- data/lib/woods/extractors/view_component_extractor.rb +1 -1
- data/lib/woods/filename_utils.rb +31 -2
- data/lib/woods/flow_assembler.rb +45 -28
- data/lib/woods/flow_precomputer.rb +2 -1
- data/lib/woods/git_provenance.rb +121 -0
- data/lib/woods/index_artifact.rb +8 -4
- data/lib/woods/mcp/bootstrapper.rb +6 -5
- data/lib/woods/mcp/config_resolver.rb +42 -21
- data/lib/woods/mcp/errors.rb +5 -3
- data/lib/woods/mcp/server.rb +68 -15
- data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
- data/lib/woods/model_name_cache.rb +6 -1
- data/lib/woods/notion/client.rb +4 -1
- data/lib/woods/notion/exporter.rb +7 -1
- data/lib/woods/obsidian/errors.rb +11 -0
- data/lib/woods/obsidian/name_mapper.rb +132 -0
- data/lib/woods/obsidian/note_builder.rb +260 -0
- data/lib/woods/obsidian/vault_assets.rb +104 -0
- data/lib/woods/obsidian/vault_exporter.rb +412 -0
- data/lib/woods/operator/error_escalator.rb +15 -4
- data/lib/woods/resilience/circuit_breaker.rb +98 -24
- data/lib/woods/resolved_config.rb +4 -1
- data/lib/woods/retrieval/ranker.rb +45 -7
- data/lib/woods/retry_after.rb +36 -0
- data/lib/woods/temporal/json_snapshot_store.rb +4 -1
- data/lib/woods/temporal/snapshot_store.rb +4 -1
- data/lib/woods/unblocked/client.rb +4 -1
- data/lib/woods/unblocked/document_builder.rb +21 -27
- data/lib/woods/update_check.rb +190 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +24 -0
- metadata +15 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f001f56232acc78a70a3b8584e1bb7dc7d94d9d0f6fa3d34a749bc963d2a361f
|
|
4
|
+
data.tar.gz: d9f5e7b0ff143d8d71e4368ce961e946cfc0a5b380793e6f4e190fedae968375
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
538
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|