woods 1.5.0 → 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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +160 -0
  3. data/CONTRIBUTING.md +30 -1
  4. data/README.md +31 -0
  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 +138 -26
  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/index_artifact.rb +5 -1
  49. data/lib/woods/mcp/server.rb +68 -15
  50. data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
  51. data/lib/woods/model_name_cache.rb +6 -1
  52. data/lib/woods/notion/client.rb +4 -1
  53. data/lib/woods/notion/exporter.rb +7 -1
  54. data/lib/woods/obsidian/errors.rb +11 -0
  55. data/lib/woods/obsidian/name_mapper.rb +132 -0
  56. data/lib/woods/obsidian/note_builder.rb +260 -0
  57. data/lib/woods/obsidian/vault_assets.rb +104 -0
  58. data/lib/woods/obsidian/vault_exporter.rb +412 -0
  59. data/lib/woods/operator/error_escalator.rb +15 -4
  60. data/lib/woods/resilience/circuit_breaker.rb +98 -24
  61. data/lib/woods/resolved_config.rb +4 -1
  62. data/lib/woods/retrieval/ranker.rb +45 -7
  63. data/lib/woods/retry_after.rb +36 -0
  64. data/lib/woods/unblocked/client.rb +4 -1
  65. data/lib/woods/unblocked/document_builder.rb +21 -27
  66. data/lib/woods/update_check.rb +190 -0
  67. data/lib/woods/version.rb +1 -1
  68. data/lib/woods.rb +24 -0
  69. metadata +12 -2
@@ -256,7 +256,7 @@ module Woods
256
256
  return 0.5 unless unit
257
257
  return 0.5 unless classification.target_type
258
258
 
259
- unit_type = dig_metadata(unit, :type) || unit[:type]
259
+ unit_type = dig_metadata(unit, :type)
260
260
  unit_type&.to_sym == classification.target_type ? 1.0 : 0.3
261
261
  end
262
262
 
@@ -297,19 +297,57 @@ module Woods
297
297
  penalty
298
298
  end
299
299
 
300
- # Dig into unit metadata, handling both hash and object access.
300
+ # Dig a value out of a unit's metadata, tolerating both symbol and
301
+ # string keys at every level.
302
+ #
303
+ # Units come from the metadata store, which returns STRING keys on
304
+ # every real backend (SQLite round-trips through JSON.parse;
305
+ # InMemory#store calls stringify_keys). The previous implementation
306
+ # probed only symbol keys, so recency/importance/type_match/diversity
307
+ # silently scored every unit at their neutral fallback in production —
308
+ # the specs passed only because they stub symbol-keyed units.
309
+ #
310
+ # Looks under the `metadata` wrapper first, then falls back to a
311
+ # top-level field for single-key lookups (namespace/type live on the
312
+ # unit itself, not under metadata).
301
313
  #
302
314
  # @param unit [Hash, Object] Unit data
303
- # @param keys [Array<Symbol>] Key path
315
+ # @param keys [Array<Symbol>] Key path within metadata
304
316
  # @return [Object, nil]
305
317
  def dig_metadata(unit, *keys)
306
- if keys.size == 1
307
- unit.is_a?(Hash) ? (unit.dig(:metadata, keys[0]) || unit[keys[0]]) : nil
308
- else
309
- unit.is_a?(Hash) ? unit.dig(:metadata, *keys) : nil
318
+ return nil unless unit.is_a?(Hash)
319
+
320
+ nested = dig_either(fetch_either(unit, :metadata), keys)
321
+ return nested unless nested.nil?
322
+
323
+ keys.size == 1 ? fetch_either(unit, keys.first) : nil
324
+ end
325
+
326
+ # Walk a key path through nested hashes, trying both key forms.
327
+ #
328
+ # @param hash [Object] Starting hash (nil-safe)
329
+ # @param keys [Array<Symbol>] Key path
330
+ # @return [Object, nil]
331
+ def dig_either(hash, keys)
332
+ keys.reduce(hash) do |acc, key|
333
+ break nil unless acc.is_a?(Hash)
334
+
335
+ fetch_either(acc, key)
310
336
  end
311
337
  end
312
338
 
339
+ # Fetch a key trying its symbol form, then its string form.
340
+ #
341
+ # @param hash [Object] Hash to read (nil-safe)
342
+ # @param key [Symbol, String] Key to fetch
343
+ # @return [Object, nil]
344
+ def fetch_either(hash, key)
345
+ return nil unless hash.is_a?(Hash)
346
+
347
+ value = hash[key]
348
+ value.nil? ? hash[key.to_s] : value
349
+ end
350
+
313
351
  # Build a Candidate struct compatible with SearchExecutor::Candidate.
314
352
  #
315
353
  # @return [Candidate-like Struct]
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time' # Time.httpdate
4
+
5
+ module Woods
6
+ # Parse an HTTP +Retry-After+ header into a number of seconds to wait.
7
+ #
8
+ # Per RFC 9110 the header is either a non-negative integer count of seconds
9
+ # or an HTTP-date. Calling +.to_f+ on the header directly (the naive
10
+ # approach) turns the HTTP-date form into +0.0+, so a client honoring it
11
+ # would retry immediately and hammer a server that is actively asking it to
12
+ # back off. This helper handles both forms and falls back to a caller-supplied
13
+ # value when the header is absent or unparseable.
14
+ module RetryAfter
15
+ module_function
16
+
17
+ # @param header [String, nil] the raw +Retry-After+ header value
18
+ # @param fallback [Numeric] seconds to use when the header is missing or unparseable
19
+ # @param now [Time] reference time for the HTTP-date form (injectable for tests)
20
+ # @return [Float] non-negative seconds to wait
21
+ def seconds(header, fallback:, now: Time.now)
22
+ value = header.to_s.strip
23
+ return fallback.to_f if value.empty?
24
+
25
+ # delta-seconds form: a bare non-negative integer.
26
+ return value.to_f if value.match?(/\A\d+\z/)
27
+
28
+ # HTTP-date form: wait until that instant, never negative.
29
+ begin
30
+ [Time.httpdate(value) - now, 0.0].max
31
+ rescue ArgumentError
32
+ fallback.to_f
33
+ end
34
+ end
35
+ end
36
+ end
@@ -4,6 +4,7 @@ require 'json'
4
4
  require 'net/http'
5
5
  require 'uri'
6
6
  require 'woods'
7
+ require_relative '../retry_after'
7
8
  require_relative 'rate_limiter'
8
9
 
9
10
  module Woods
@@ -175,7 +176,9 @@ module Woods
175
176
 
176
177
  if response.code == '429' && retries < MAX_RETRIES
177
178
  retries += 1
178
- wait_time = (response['Retry-After'] || (retries * 2)).to_f
179
+ # Retry-After may be an HTTP-date, which .to_f would collapse to
180
+ # 0.0 — honoring it as-is would hammer a throttling server.
181
+ wait_time = Woods::RetryAfter.seconds(response['Retry-After'], fallback: retries * 2)
179
182
  sleep(wait_time)
180
183
  next
181
184
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'woods/export/unit_facts'
4
+
3
5
  module Woods
4
6
  module Unblocked
5
7
  # Converts extracted unit JSON into condensed Markdown documents
@@ -96,10 +98,10 @@ module Woods
96
98
  sections = []
97
99
 
98
100
  sections << model_header(unit, meta)
99
- sections << model_associations(meta)
101
+ sections << model_associations(unit)
100
102
  sections << model_dependents(unit)
101
103
  sections << model_entry_points(unit)
102
- sections << model_schema_highlights(meta)
104
+ sections << model_schema_highlights(unit)
103
105
  sections << model_side_effects(unit)
104
106
 
105
107
  sections.compact.join("\n\n")
@@ -116,22 +118,18 @@ module Woods
116
118
  parts.join("\n")
117
119
  end
118
120
 
119
- def model_associations(meta)
120
- assocs = meta['associations'] || []
121
- return nil if assocs.empty?
121
+ def model_associations(unit)
122
+ facts = Woods::Export::UnitFacts.new(unit)
123
+ by_type = facts.associations_by_type
124
+ return nil if by_type.empty?
122
125
 
123
- grouped = assocs.group_by { |a| a['type'] }
124
- lines = ["## Associations (#{assocs.size})"]
126
+ lines = ["## Associations (#{facts.association_count})"]
125
127
 
126
128
  %w[belongs_to has_many has_one has_and_belongs_to_many].each do |type|
127
- items = grouped[type]
129
+ items = by_type[type]
128
130
  next unless items&.any?
129
131
 
130
- targets = items.map do |a|
131
- name = a['target'] || a['name']
132
- dep = a.dig('options', 'dependent')
133
- dep ? "#{name} (#{dep})" : name
134
- end
132
+ targets = items.map { |a| a[:dependent] ? "#{a[:target]} (#{a[:dependent]})" : a[:target] }
135
133
  # Sorted so the body is a function of association content, not order
136
134
  # (the exporter hashes this body to detect changes).
137
135
  lines << "**#{type}:** #{targets.sort.join(', ')}"
@@ -176,25 +174,21 @@ module Woods
176
174
  lines.join("\n")
177
175
  end
178
176
 
179
- def model_schema_highlights(meta)
177
+ def model_schema_highlights(unit)
178
+ highlights = Woods::Export::UnitFacts.new(unit).schema_highlights
180
179
  parts = []
181
180
 
182
- enums = meta['enums']
183
- if enums.is_a?(Hash) && enums.any?
184
- enum_strs = enums.sort_by { |name, _| name.to_s }
185
- .map { |name, values| "#{name} (#{format_enum_values(values)})" }
181
+ if highlights[:enums].any?
182
+ enum_strs = highlights[:enums].sort_by { |name, _| name.to_s }
183
+ .map { |name, values| "#{name} (#{format_enum_values(values)})" }
186
184
  parts << "**Enums:** #{enum_strs.join('; ')}"
187
185
  end
188
186
 
189
- scopes = meta['scopes']
190
- parts << "**Scopes:** #{scopes.map { |s| s['name'] }.sort.join(', ')}" if scopes.is_a?(Array) && scopes.any?
191
-
192
- concerns = meta['inlined_concerns']
193
- parts << "**Concerns:** #{concerns.sort.join(', ')}" if concerns.is_a?(Array) && concerns.any?
187
+ parts << "**Scopes:** #{highlights[:scopes].sort.join(', ')}" if highlights[:scopes].any?
188
+ parts << "**Concerns:** #{highlights[:concerns].sort.join(', ')}" if highlights[:concerns].any?
194
189
 
195
- callbacks = meta['callbacks']
196
- if callbacks.is_a?(Array) && callbacks.any?
197
- parts << "**Callbacks (#{callbacks.size}):** #{format_callbacks(callbacks)}"
190
+ if highlights[:callbacks].any?
191
+ parts << "**Callbacks (#{highlights[:callbacks].size}):** #{format_callbacks(highlights[:callbacks])}"
198
192
  end
199
193
 
200
194
  return nil if parts.empty?
@@ -333,7 +327,7 @@ module Woods
333
327
  def format_callbacks(callbacks)
334
328
  # Sort before truncating so both the selection and order are stable
335
329
  # regardless of input order (the body is hashed for change detection).
336
- callbacks.map { |cb| "#{cb['type']}: #{cb['filter']}" }.sort.first(5).join(', ')
330
+ callbacks.map { |cb| "#{cb[:type]}: #{cb[:filter]}" }.sort.first(5).join(', ')
337
331
  end
338
332
  end
339
333
  end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require 'tmpdir'
6
+ require_relative 'version'
7
+ require_relative 'atomic_file'
8
+
9
+ module Woods
10
+ # Best-effort "is a newer Woods release available?" check.
11
+ #
12
+ # Modeled on grove's background update notifier: it queries RubyGems for the
13
+ # latest published +woods+ version, caches the answer on disk for 24h, and is
14
+ # fully non-fatal — any network, parse, or filesystem failure degrades to
15
+ # "no update signal" rather than raising. It exists so the MCP server can tell
16
+ # an agent (and, through it, the user) that the installed gem is behind, which
17
+ # matters because the distributed guide skills operate against whatever version
18
+ # is installed.
19
+ #
20
+ # Two surfaces consume this:
21
+ # * {Woods::MCP::Server.build_status} embeds {status_hash} under
22
+ # +server.update+ so +woods_status+ reports update availability.
23
+ # * {Woods::MCP::VersionAwareToolDispatch} uses {tool_not_found_message} to
24
+ # turn a bare "Tool not found" into version-aware, self-healing guidance.
25
+ #
26
+ # Disable entirely with +WOODS_NO_UPDATE_CHECK=1+.
27
+ #
28
+ # @example
29
+ # Woods::UpdateCheck.status_hash
30
+ # # => { current_version: "1.5.0", latest_version: "1.6.0", update_available: true }
31
+ module UpdateCheck
32
+ # RubyGems endpoint returning +{ "version": "x.y.z" }+ for the latest release.
33
+ RUBYGEMS_LATEST_URL = 'https://rubygems.org/api/v1/versions/woods/latest.json'
34
+ # How long a successful result is trusted before a re-fetch is attempted.
35
+ CACHE_TTL = 24 * 60 * 60
36
+ # A *failed* probe is cached for a shorter window, so an unreachable or slow
37
+ # RubyGems throttles retries (rather than re-blocking on every call) without
38
+ # hiding a real update for a full day once connectivity returns.
39
+ FAILURE_TTL = 60 * 60
40
+ # Open/read timeout for the (best-effort) network probe, in seconds.
41
+ HTTP_TIMEOUT = 1.5
42
+
43
+ module_function
44
+
45
+ # Resolve update availability, using the on-disk cache when fresh and
46
+ # otherwise fetching once and caching the result.
47
+ #
48
+ # @param current [String] Installed version (defaults to {Woods::VERSION})
49
+ # @param cache_path [String] Path to the JSON cache file
50
+ # @param ttl [Integer] Cache lifetime in seconds
51
+ # @param now [Time] Injected clock (for deterministic specs)
52
+ # @param fetcher [#call] Callable taking the URL and returning a version
53
+ # string or nil; injectable for tests
54
+ # @return [Hash] +{ current:, latest:, update_available: }+ (latest may be nil)
55
+ def check(current: Woods::VERSION, cache_path: default_cache_path, ttl: CACHE_TTL,
56
+ now: Time.now, fetcher: method(:fetch_latest_version))
57
+ return result(current, nil) if disabled?
58
+
59
+ result(current, cached_or_refreshed_latest(cache_path, ttl, now, fetcher))
60
+ end
61
+
62
+ # The +server.update+ sub-hash for +woods_status+. Keys are snake_case to
63
+ # match the rest of the status payload's JSON shape.
64
+ #
65
+ # @return [Hash] +{ current_version:, latest_version:, update_available: }+
66
+ def status_hash(current: Woods::VERSION, **opts)
67
+ r = check(current: current, **opts)
68
+ {
69
+ current_version: r[:current],
70
+ latest_version: r[:latest],
71
+ update_available: r[:update_available]
72
+ }
73
+ end
74
+
75
+ # Version-aware replacement for the MCP layer's bare "Tool not found"
76
+ # message. Cache-only — it never triggers a network fetch, since it runs on
77
+ # an error path that must stay cheap.
78
+ #
79
+ # @param tool_name [String] The unrecognized tool name
80
+ # @param current [String] Installed version
81
+ # @return [String] Guidance an agent can relay to the user
82
+ def tool_not_found_message(tool_name, current: Woods::VERSION, cache_path: default_cache_path, fetcher: nil)
83
+ _ = fetcher # accepted so callers/specs can prove it is never invoked on this path
84
+ latest = disabled? ? nil : read_cache(cache_path)&.fetch('latest', nil)
85
+ msg = "Tool not found: #{tool_name}. This tool is not available in the installed " \
86
+ "Woods v#{current}. It may require a newer release — advise the user to run " \
87
+ '`bundle update woods`, then reconnect the MCP server.'
88
+ msg << " (latest published: #{latest})" if latest && newer?(latest, current)
89
+ msg
90
+ end
91
+
92
+ # --- internals -----------------------------------------------------------
93
+
94
+ def disabled?
95
+ ENV['WOODS_NO_UPDATE_CHECK'] == '1'
96
+ end
97
+
98
+ def result(current, latest)
99
+ { current: current, latest: latest, update_available: latest ? newer?(latest, current) : false }
100
+ end
101
+
102
+ def newer?(latest, current)
103
+ Gem::Version.new(latest) > Gem::Version.new(current)
104
+ rescue ArgumentError
105
+ false
106
+ end
107
+
108
+ # Return the cached latest version when the cache entry is still fresh
109
+ # (a shorter window applies to a previously-failed probe), otherwise fetch
110
+ # once and cache the outcome — success *or* failure.
111
+ #
112
+ # @return [String, nil]
113
+ def cached_or_refreshed_latest(cache_path, ttl, now, fetcher)
114
+ entry = read_cache(cache_path)
115
+ return entry['latest'] if entry && fresh_entry?(entry, ttl, now)
116
+
117
+ refresh(cache_path, now, fetcher)
118
+ end
119
+
120
+ # A success entry is trusted for +ttl+; a failure entry (nil latest) only
121
+ # for {FAILURE_TTL}.
122
+ def fresh_entry?(entry, ttl, now)
123
+ checked_at = entry['checked_at']
124
+ return false unless checked_at.is_a?(Numeric)
125
+
126
+ effective_ttl = entry['latest'] ? ttl : FAILURE_TTL
127
+ (now.to_i - checked_at) < effective_ttl
128
+ end
129
+
130
+ # Fetch and cache the outcome. A nil latest (unreachable RubyGems, non-2xx,
131
+ # unparseable body) is cached too, so repeated failures are throttled by
132
+ # {FAILURE_TTL} instead of re-probing on every call.
133
+ #
134
+ # @return [String, nil] the latest version, or nil on failure
135
+ def refresh(cache_path, now, fetcher)
136
+ latest = fetcher.call(RUBYGEMS_LATEST_URL)
137
+ write_cache(cache_path, latest, now)
138
+ latest
139
+ rescue StandardError
140
+ write_cache(cache_path, nil, now)
141
+ nil
142
+ end
143
+
144
+ # @return [Hash, nil] the parsed cache entry, or nil if absent, unreadable,
145
+ # or not a JSON object (guards the "never raise" contract against a
146
+ # corrupt/tampered cache holding e.g. +[]+ or +42+)
147
+ def read_cache(cache_path)
148
+ return nil unless File.exist?(cache_path)
149
+
150
+ parsed = JSON.parse(File.read(cache_path))
151
+ parsed.is_a?(Hash) ? parsed : nil
152
+ rescue StandardError
153
+ nil
154
+ end
155
+
156
+ def write_cache(cache_path, latest, now)
157
+ Woods::AtomicFile.write(cache_path, JSON.generate('latest' => latest, 'checked_at' => now.to_i))
158
+ rescue StandardError
159
+ nil
160
+ end
161
+
162
+ # Default per-user cache location, honoring XDG, falling back to tmpdir.
163
+ def default_cache_path
164
+ base = ENV.fetch('XDG_CACHE_HOME', nil)
165
+ base = File.join(Dir.home, '.cache') if base.nil? || base.empty?
166
+ File.join(base, 'woods', 'update_check.json')
167
+ rescue StandardError
168
+ File.join(Dir.tmpdir, 'woods-update-check.json')
169
+ end
170
+
171
+ # Best-effort RubyGems probe. Kept tiny and self-contained so the module has
172
+ # no non-stdlib dependencies.
173
+ #
174
+ # @return [String, nil] latest version string, or nil on any failure
175
+ def fetch_latest_version(url)
176
+ require 'net/http'
177
+ uri = URI(url)
178
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
179
+ open_timeout: HTTP_TIMEOUT, read_timeout: HTTP_TIMEOUT) do |http|
180
+ http.get(uri.request_uri)
181
+ end
182
+ return nil unless response.is_a?(Net::HTTPSuccess)
183
+
184
+ version = JSON.parse(response.body)['version']
185
+ version if version.is_a?(String) && !version.empty?
186
+ rescue StandardError
187
+ nil
188
+ end
189
+ end
190
+ end
data/lib/woods/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Woods
4
- VERSION = '1.5.0'
4
+ VERSION = '1.6.0'
5
5
  end
data/lib/woods.rb CHANGED
@@ -319,6 +319,30 @@ module Woods
319
319
  end
320
320
  end
321
321
 
322
+ # Resolve the effective Notion API token.
323
+ #
324
+ # A non-blank +NOTION_API_TOKEN+ env var takes precedence; otherwise the
325
+ # configured +notion_api_token+ is used. A set-but-blank env var — common
326
+ # with docker-compose +${NOTION_API_TOKEN}+ interpolation of an unset
327
+ # host variable — is treated as absent, so it never masks a valid
328
+ # configured token nor slips through as a blank bearer. This is the single
329
+ # resolution point shared by the Notion exporter, the MCP +notion_sync+
330
+ # tool + its registration gate, and the rake task, so all four agree on a
331
+ # given host.
332
+ #
333
+ # @param config [Configuration] configuration to read the fallback from
334
+ # @return [String, nil] the resolved token, or nil when neither source
335
+ # supplies a non-blank value
336
+ def resolve_notion_token(config = configuration)
337
+ env = ENV.fetch('NOTION_API_TOKEN', nil)
338
+ return env unless env.nil? || env.strip.empty?
339
+
340
+ configured = config.respond_to?(:notion_api_token) ? config.notion_api_token : nil
341
+ return nil if configured.nil? || configured.to_s.strip.empty?
342
+
343
+ configured
344
+ end
345
+
322
346
  # Build a Retriever wired with adapters from the current configuration.
323
347
  #
324
348
  # @return [Retriever] A fully wired retriever instance
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: woods
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leah Armstrong
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-24 00:00:00.000000000 Z
11
+ date: 2026-07-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mcp
@@ -112,6 +112,7 @@ files:
112
112
  - lib/woods/ast/method_extractor.rb
113
113
  - lib/woods/ast/node.rb
114
114
  - lib/woods/ast/parser.rb
115
+ - lib/woods/atomic_file.rb
115
116
  - lib/woods/builder.rb
116
117
  - lib/woods/cache/cache_middleware.rb
117
118
  - lib/woods/cache/cache_store.rb
@@ -176,6 +177,7 @@ files:
176
177
  - lib/woods/evaluation/metrics.rb
177
178
  - lib/woods/evaluation/query_set.rb
178
179
  - lib/woods/evaluation/report_generator.rb
180
+ - lib/woods/export/unit_facts.rb
179
181
  - lib/woods/extracted_unit.rb
180
182
  - lib/woods/extractor.rb
181
183
  - lib/woods/extractors/action_cable_extractor.rb
@@ -250,6 +252,7 @@ files:
250
252
  - lib/woods/mcp/renderers/plain_renderer.rb
251
253
  - lib/woods/mcp/server.rb
252
254
  - lib/woods/mcp/tool_response_renderer.rb
255
+ - lib/woods/mcp/version_aware_tool_dispatch.rb
253
256
  - lib/woods/model_name_cache.rb
254
257
  - lib/woods/notion/client.rb
255
258
  - lib/woods/notion/exporter.rb
@@ -262,6 +265,11 @@ files:
262
265
  - lib/woods/observability/health_check.rb
263
266
  - lib/woods/observability/instrumentation.rb
264
267
  - lib/woods/observability/structured_logger.rb
268
+ - lib/woods/obsidian/errors.rb
269
+ - lib/woods/obsidian/name_mapper.rb
270
+ - lib/woods/obsidian/note_builder.rb
271
+ - lib/woods/obsidian/vault_assets.rb
272
+ - lib/woods/obsidian/vault_exporter.rb
265
273
  - lib/woods/operator/error_escalator.rb
266
274
  - lib/woods/operator/pipeline_guard.rb
267
275
  - lib/woods/operator/status_reporter.rb
@@ -275,6 +283,7 @@ files:
275
283
  - lib/woods/retrieval/ranker.rb
276
284
  - lib/woods/retrieval/search_executor.rb
277
285
  - lib/woods/retriever.rb
286
+ - lib/woods/retry_after.rb
278
287
  - lib/woods/ruby_analyzer.rb
279
288
  - lib/woods/ruby_analyzer/class_analyzer.rb
280
289
  - lib/woods/ruby_analyzer/dataflow_analyzer.rb
@@ -307,6 +316,7 @@ files:
307
316
  - lib/woods/unblocked/exporter.rb
308
317
  - lib/woods/unblocked/rate_limiter.rb
309
318
  - lib/woods/unblocked/sync_manifest.rb
319
+ - lib/woods/update_check.rb
310
320
  - lib/woods/util/host_guard.rb
311
321
  - lib/woods/version.rb
312
322
  homepage: https://github.com/lost-in-the/woods