woods 2.0.0.beta4 → 2.0.1

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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +495 -471
  3. data/CONTRIBUTING.md +12 -2
  4. data/README.md +11 -26
  5. data/docs/AGENT_GUIDE.md +31 -12
  6. data/docs/AGENT_SETUP.md +17 -10
  7. data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
  8. data/docs/BACKEND_MATRIX.md +13 -7
  9. data/docs/CLIENT_HOOKS.md +1 -1
  10. data/docs/CONFIGURATION_REFERENCE.md +44 -27
  11. data/docs/CONSOLE_MCP_SETUP.md +95 -16
  12. data/docs/DOCKER_SETUP.md +15 -0
  13. data/docs/EVALUATION.md +10 -4
  14. data/docs/EXTRACTOR_REFERENCE.md +14 -2
  15. data/docs/FAQ.md +14 -3
  16. data/docs/GETTING_STARTED.md +18 -17
  17. data/docs/INCREMENTAL_EXTRACTION.md +8 -3
  18. data/docs/INDEX_LAYOUT.md +2 -2
  19. data/docs/MCP_HTTP_TRANSPORT.md +54 -2
  20. data/docs/MCP_SERVERS.md +28 -11
  21. data/docs/MCP_TOOL_COOKBOOK.md +1 -1
  22. data/docs/MCP_WORKTREE_SETUP.md +13 -1
  23. data/docs/PUBLISHED_INDEX.md +1 -1
  24. data/docs/README.md +2 -1
  25. data/docs/RETRIEVAL_GUIDE.md +57 -8
  26. data/docs/SOURCE_FRESHNESS.md +1 -1
  27. data/docs/TOKEN_BENCHMARK.md +16 -10
  28. data/docs/TROUBLESHOOTING.md +133 -37
  29. data/docs/UPGRADING_TO_2.md +69 -7
  30. data/docs/WATCH_DAEMON.md +172 -17
  31. data/docs/WHY_WOODS.md +9 -5
  32. data/exe/woods-console +13 -11
  33. data/exe/woods-mcp-http +16 -9
  34. data/exe/woods-watch +5 -0
  35. data/lib/generators/woods/watch_generator.rb +53 -0
  36. data/lib/puma/plugin/woods.rb +10 -0
  37. data/lib/tasks/woods.rake +14 -0
  38. data/lib/woods/cache/cache_middleware.rb +18 -11
  39. data/lib/woods/console/adapter_family.rb +39 -0
  40. data/lib/woods/console/credential_index.rb +33 -3
  41. data/lib/woods/console/embedded_executor.rb +401 -43
  42. data/lib/woods/console/model_validator.rb +8 -0
  43. data/lib/woods/console/rack_middleware.rb +39 -10
  44. data/lib/woods/console/redactor.rb +24 -10
  45. data/lib/woods/console/safe_context.rb +44 -7
  46. data/lib/woods/console/sql_noise_stripper.rb +41 -12
  47. data/lib/woods/console/sql_table_scanner.rb +45 -34
  48. data/lib/woods/console/sql_validator.rb +37 -2
  49. data/lib/woods/console/stdio_transport.rb +27 -0
  50. data/lib/woods/extractor.rb +25 -7
  51. data/lib/woods/git_command.rb +6 -7
  52. data/lib/woods/git_provenance.rb +4 -6
  53. data/lib/woods/mcp/bearer_auth.rb +1 -1
  54. data/lib/woods/mcp/bootstrapper.rb +3 -1
  55. data/lib/woods/mcp/initialization_guidance.rb +1 -1
  56. data/lib/woods/mcp/origin_guard.rb +24 -77
  57. data/lib/woods/mcp/origin_policy.rb +124 -0
  58. data/lib/woods/mcp/server.rb +41 -9
  59. data/lib/woods/railtie_support.rb +8 -0
  60. data/lib/woods/retrieval/corpus_status.rb +46 -0
  61. data/lib/woods/retriever.rb +19 -7
  62. data/lib/woods/storage/local_corpus_stats.rb +32 -0
  63. data/lib/woods/storage/metadata_store.rb +20 -0
  64. data/lib/woods/storage/vector_store.rb +10 -0
  65. data/lib/woods/version.rb +1 -1
  66. data/lib/woods/watch/child_environment.rb +30 -0
  67. data/lib/woods/watch/cli.rb +91 -0
  68. data/lib/woods/watch/daemon.rb +55 -7
  69. data/lib/woods/watch/event_stream.rb +70 -0
  70. data/lib/woods/watch/guardian.rb +142 -0
  71. data/lib/woods/watch/installation/layout.rb +70 -0
  72. data/lib/woods/watch/installation/options.rb +128 -0
  73. data/lib/woods/watch/installation/planner.rb +128 -0
  74. data/lib/woods/watch/installation/probe.rb +101 -0
  75. data/lib/woods/watch/installation/receipt.rb +77 -0
  76. data/lib/woods/watch/installation/recovery.rb +64 -0
  77. data/lib/woods/watch/installation/templates.rb +58 -0
  78. data/lib/woods/watch/installation.rb +56 -0
  79. data/lib/woods/watch/lifecycle.rb +182 -0
  80. data/lib/woods/watch/managed_child.rb +113 -0
  81. data/lib/woods/watch/managed_cleanup.rb +48 -0
  82. data/lib/woods/watch/managed_process.rb +144 -0
  83. data/lib/woods/watch/puma_adapter.rb +87 -0
  84. data/lib/woods/watch/puma_child.rb +66 -0
  85. data/lib/woods/watch/supervision_records.rb +95 -0
  86. data/lib/woods/watch/supervision_status.rb +104 -0
  87. data/lib/woods/watch/supervisor.rb +161 -0
  88. data/lib/woods/watch/supervisor_reporting.rb +46 -0
  89. data/plugin/.claude-plugin/plugin.json +1 -1
  90. data/plugin/skills/woods-agent-enable/SKILL.md +1 -1
  91. data/plugin/skills/woods-diagnose/SKILL.md +88 -8
  92. data/plugin/skills/woods-investigate/SKILL.md +6 -6
  93. data/plugin/skills/woods-mcp-config/SKILL.md +43 -1
  94. data/plugin/skills/woods-setup/SKILL.md +66 -4
  95. metadata +37 -5
@@ -2103,10 +2103,28 @@ module Woods
2103
2103
  end
2104
2104
 
2105
2105
  @git_available = complete_git_history?
2106
+ rescue Errno::ENOENT
2107
+ warn_missing_git_executable
2108
+ @git_available = false
2106
2109
  rescue StandardError
2107
2110
  @git_available = false
2108
2111
  end
2109
2112
 
2113
+ # An explicit Git directory still requests history when .git is not mounted
2114
+ # at Rails.root. Ordinary source archives remain a supported, quiet path.
2115
+ #
2116
+ # @return [void]
2117
+ def warn_missing_git_executable
2118
+ configured = [GitCommand::OVERRIDE_KEY, 'GIT_DIR'].any? { |key| !ENV[key].to_s.empty? }
2119
+ return unless configured || File.exist?(File.join(Rails.root.to_s, '.git'))
2120
+
2121
+ Rails.logger.warn(
2122
+ '[Woods] Git history unavailable: git executable was not found in PATH. ' \
2123
+ 'Extraction continues without per-unit Git metadata. Install Git in the extraction environment ' \
2124
+ 'and run a full extraction to refresh Git metadata.'
2125
+ )
2126
+ end
2127
+
2110
2128
  # A shallow HEAD resolves but represents an incomplete ancestry. Do not
2111
2129
  # turn that boundary into apparent one-commit/new-file churn facts.
2112
2130
  def complete_git_history?
@@ -2134,19 +2152,19 @@ module Woods
2134
2152
  cause = error.to_s.lines.first.to_s.strip
2135
2153
  Rails.logger.warn(
2136
2154
  '[Woods] git cannot resolve HEAD for this working tree, so no unit will carry git ' \
2137
- "metadata: #{cause}. Over a linked worktree in a container, mount the canonical git " \
2138
- 'directory and point WOODS_GIT_DIR at it; GIT_DIR alone is not enough, because the ' \
2139
- "worktree's private git directory reaches the shared one through a relative pointer."
2155
+ "metadata: #{cause}. For a linked worktree, mount the complete shared .git directory " \
2156
+ 'at its original path, or set WOODS_GIT_DIR to /mounted-common/worktrees/<id> within ' \
2157
+ 'the relocated complete layout. Derive <id> from the worktree Git metadata, not its branch name; ' \
2158
+ "selecting /mounted-common itself uses the primary checkout's HEAD."
2140
2159
  )
2141
2160
  end
2142
2161
 
2143
2162
  # The git command line every enrichment call runs.
2144
2163
  #
2145
2164
  # `-C <root>` keeps the result independent of the process working
2146
- # directory. `WOODS_GIT_DIR` wins when set: it names the canonical git
2147
- # directory directly, which is the escape hatch for a container that can
2148
- # mount that directory but not the host path a worktree pointer names
2149
- # (B-181).
2165
+ # directory. `WOODS_GIT_DIR` wins when set and selects that git directory's
2166
+ # HEAD. A relocated linked worktree needs its worktree-specific directory
2167
+ # inside the complete shared Git layout (B-181).
2150
2168
  #
2151
2169
  # @param args [Array<String>] git arguments
2152
2170
  # @return [Array<String>] full argv
@@ -7,18 +7,17 @@ module Woods
7
7
  # than at the process working directory, so extraction launched from another
8
8
  # checkout never reports that checkout's history.
9
9
  #
10
- # `WOODS_GIT_DIR` wins when set. It is the escape hatch for a container over
11
- # a linked worktree: `.git` there is a file naming an absolute host path that
12
- # may not be mounted, and git's own `GIT_DIR` is not enough, because a
13
- # worktree's private git directory reaches the shared object store through a
14
- # relative `commondir` pointer that resolves outside the mount, which
15
- # `GIT_COMMON_DIR` does not override (B-181, B-186).
10
+ # `WOODS_GIT_DIR` wins when set and selects HEAD exactly as `--git-dir`
11
+ # does. For a container over a linked worktree, select its private directory
12
+ # within the complete mounted Git layout (shared objects, refs and worktrees).
13
+ # Selecting the shared root instead selects the primary checkout's HEAD.
14
+ # A same-path mount that resolves the worktree's .git pointer needs no override.
16
15
  #
17
16
  # Three call sites use this, and the documentation promises all three:
18
17
  # per-unit enrichment (`Extractor`), manifest provenance (`GitProvenance`),
19
18
  # and the `woods:incremental` diff range (`lib/tasks/woods.rake`).
20
19
  module GitCommand
21
- # Environment variable naming the canonical git directory.
20
+ # Environment variable naming the explicitly selected git directory.
22
21
  OVERRIDE_KEY = 'WOODS_GIT_DIR'
23
22
 
24
23
  module_function
@@ -85,12 +85,10 @@ module Woods
85
85
  ''
86
86
  end
87
87
 
88
- # +WOODS_GIT_DIR+ names the canonical git directory outright and wins when
89
- # set. It is the escape hatch for a container that can mount that
90
- # directory but not the host path a linked worktree's +.git+ file points
91
- # at; git's own +GIT_DIR+ is not enough there, because a worktree's
92
- # private git directory reaches the shared one through a relative
93
- # +commondir+ pointer that resolves outside the mount.
88
+ # +WOODS_GIT_DIR+ selects a git directory and its HEAD outright and wins when
89
+ # set. A relocated linked worktree needs its private directory inside the
90
+ # complete mounted Git layout, preserving access to shared objects and refs.
91
+ # Selecting the shared root instead uses the primary checkout's HEAD.
94
92
  #
95
93
  # @param args [Array<String>] git arguments
96
94
  # @return [Array<String>] full argv
@@ -55,7 +55,7 @@ module Woods
55
55
  expected = resolve_token
56
56
  header = env['HTTP_AUTHORIZATION'].to_s
57
57
  # Only the ASCII scheme changes case; do not normalize or trim the token.
58
- presented = header.match?(/\A[Bb][Ee][Aa][Rr][Ee][Rr] /) ? header[7..] : nil
58
+ presented = header.b.match?(/\A[Bb][Ee][Aa][Rr][Ee][Rr] /) ? header.byteslice(7..) : nil
59
59
 
60
60
  if expected && presented && Rack::Utils.secure_compare(expected, presented)
61
61
  @app.call(env)
@@ -176,7 +176,9 @@ module Woods
176
176
  retriever = build_retriever_from_config(config, resolved, artifact, state)
177
177
  probe_and_mark_state(config, state)
178
178
  derive_state_from_store_health(state)
179
- warn "[woods-mcp] semantic search: #{state.status} (#{config.embedding_provider})"
179
+ corpus = retriever.corpus_status(include_types: false) if retriever.respond_to?(:corpus_status)
180
+ corpus_note = corpus ? "; corpus: #{corpus[:state]}" : ''
181
+ warn "[woods-mcp] semantic search: #{state.status} (#{config.embedding_provider})#{corpus_note}"
180
182
 
181
183
  [retriever, state]
182
184
  end
@@ -11,7 +11,7 @@ module Woods
11
11
 
12
12
  WORKFLOW = <<~TEXT
13
13
  Start with woods_status: check index readiness, generation freshness and relevant type counts before relying on results.
14
- For exact names, discover identifiers with search (prefer literal exact_prefix/exact_suffix), then inspect with lookup. For conceptual questions, use codebase_retrieve only when woods_status reports retrieval enabled. Otherwise use search and lookup.
14
+ For exact names, discover identifiers with search (prefer literal exact_prefix/exact_suffix), then inspect with lookup. For conceptual questions, check retrieval mode and data in woods_status before codebase_retrieve; structural readiness alone is insufficient. Otherwise use search and lookup.
15
15
  Follow dependencies or dependents at depth 1 or 2; narrow types and via before paging. Respect partial results and limits: a missing match is not proof of absence, and a partial traversal does not establish every dependent or leaf.
16
16
  Verify important conclusions against current source and tests. Recorded relationships and inferred downstream impact do not prove runtime execution or test coverage.
17
17
  Registration does not authorize extraction, configuration changes, or live Console access. Use only the tools registered here and operate within the user's authorized scope.
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
 
5
- require_relative '../util/host_guard'
5
+ require_relative 'origin_policy'
6
6
 
7
7
  module Woods
8
8
  module MCP
@@ -21,17 +21,13 @@ module Woods
21
21
  # also requiring Host to appear in the allow-list (or to be a loopback
22
22
  # address), we close that gap even when Rails is bound to 0.0.0.0.
23
23
  #
24
- # Port-matching: an allow-list entry WITHOUT a port (`http://localhost`)
25
- # matches that host on any port. An entry WITH a port (`http://localhost:3000`)
26
- # requires an exact port match. Specify explicit ports when port isolation
27
- # matters.
24
+ # Cross-origin entries match exactly. A portless entry also permits
25
+ # same-authority requests on other ports; cross-port browser clients need
26
+ # their actual origin explicitly configured, as required by the SDK.
28
27
  #
29
28
  # Also answers CORS preflight (OPTIONS) with the matching allow-list.
30
29
  class OriginGuard
31
- DEFAULT_ALLOWED = %w[
32
- http://localhost http://127.0.0.1 http://[::1]
33
- https://localhost https://127.0.0.1 https://[::1]
34
- ].freeze
30
+ DEFAULT_ALLOWED = OriginPolicy::DEFAULT_ORIGINS
35
31
 
36
32
  # Hosts that always pass the Host-header check even without an explicit
37
33
  # allow-list entry — they resolve to loopback by definition and cannot
@@ -56,6 +52,7 @@ module Woods
56
52
  # which runs after Rails railtie initializers captured the middleware
57
53
  # arguments — still takes effect (#183). Empty/nil falls back to
58
54
  # {DEFAULT_ALLOWED}.
55
+ # @param policy [OriginPolicy, nil] Captured policy also passed to the SDK
59
56
  # @param path [String, nil] When set, only requests whose PATH_INFO
60
57
  # starts with this prefix are guarded — everything else passes
61
58
  # straight through to the app. Nil (the default) guards every request.
@@ -82,27 +79,35 @@ module Woods
82
79
  method = env['REQUEST_METHOD']
83
80
  host = env['HTTP_HOST']
84
81
 
85
- return forbidden if origin && !origin_allowed?(origin)
86
- return forbidden_host if host && !host_allowed?(host)
82
+ return forbidden unless policy.origin_allowed?(origin, host: host)
83
+ return forbidden_host unless policy.host_allowed?(host)
87
84
 
88
85
  return preflight(origin) if method == 'OPTIONS'
89
86
 
90
87
  status, headers, body = @app.call(env)
91
- headers = cors_headers(origin).merge(headers) if origin && origin_allowed?(origin)
88
+ headers = cors_headers(origin).merge(headers) if origin
92
89
  [status, headers, body]
93
90
  end
94
91
 
92
+ # The same lazily captured immutable policy is passed to the transport.
93
+ # @return [OriginPolicy]
94
+ def policy
95
+ return @policy if @policy
96
+
97
+ @policy_mutex.synchronize do
98
+ @policy ||= OriginPolicy.new(allowed_origins: @allowed_source.call)
99
+ end
100
+ end
101
+
95
102
  private
96
103
 
97
- def initialize_options(app, allowed_origins: nil, path: nil, enabled: nil)
104
+ def initialize_options(app, allowed_origins: nil, path: nil, enabled: nil, policy: nil)
98
105
  @app = app
99
106
  @path = path
100
107
  @enabled = enabled
101
- if allowed_origins.respond_to?(:call)
102
- @allowed_source = allowed_origins
103
- else
104
- build_allow_list(allowed_origins)
105
- end
108
+ @policy = policy
109
+ @policy_mutex = Mutex.new
110
+ @allowed_source = allowed_origins.respond_to?(:call) ? allowed_origins : -> { allowed_origins }
106
111
  end
107
112
 
108
113
  # @param env [Hash] Rack environment
@@ -114,66 +119,8 @@ module Woods
114
119
  true
115
120
  end
116
121
 
117
- # Normalize a raw allow-list into `@allowed` + `@allowed_hosts`.
118
- #
119
- # @param origins [Array<String>, nil]
120
- # @return [Array<String>] the normalized allow-list
121
- def build_allow_list(origins)
122
- allowed = Array(origins).compact.reject { |o| o.to_s.strip.empty? }.map { |o| normalize(o) }
123
- allowed = DEFAULT_ALLOWED.dup if allowed.empty?
124
- @allowed_hosts = allowed.map { |o| extract_host(o) }.compact.uniq
125
- @allowed = allowed
126
- end
127
-
128
- # The allow-list, resolving a deferred source on first use. Memoized —
129
- # configuration is settled by the time the first request arrives.
130
- #
131
- # @return [Array<String>]
132
- def allowed
133
- @allowed || build_allow_list(@allowed_source.call)
134
- end
135
-
136
- # @return [Array<String>] hosts extracted from the allow-list
137
- def allowed_hosts
138
- allowed
139
- @allowed_hosts
140
- end
141
-
142
- def normalize(origin)
143
- origin.to_s.sub(%r{/\z}, '').downcase
144
- end
145
-
146
- def extract_host(origin)
147
- host = origin.to_s.sub(%r{\Ahttps?://}, '').sub(%r{/.*\z}, '').downcase
148
- host.empty? ? nil : host
149
- end
150
-
151
- def host_allowed?(host)
152
- # Canonicalize (strip port, trailing dot, IPv6 brackets) via the
153
- # shared helper so Qdrant and OriginGuard stay in sync on bypass
154
- # notations. `normalized` keeps the port for literal allow-list
155
- # lookups; `bare` drops it for loopback matching.
156
- normalized = host.to_s.downcase.sub(/\.\z/, '')
157
- bare = Util::HostGuard.canonicalize(host)
158
-
159
- # Reject non-canonical numeric hosts. Net::HTTP / getaddrinfo
160
- # would happily resolve `0x7f000001` or `2130706433` to 127.0.0.1,
161
- # bypassing the loopback allow-list.
162
- return false if Util::HostGuard.suspicious_numeric_host?(bare)
163
-
164
- return true if LOOPBACK_HOSTS.include?(bare)
165
-
166
- allowed_hosts.include?(normalized) || allowed_hosts.include?(bare)
167
- end
168
-
169
- def origin_allowed?(origin)
170
- return false if origin.match?(/[[:cntrl:]]/)
171
-
172
- allowed.include?(normalize(origin)) || allowed.include?(normalize(origin).sub(/:\d+\z/, ''))
173
- end
174
-
175
122
  def preflight(origin)
176
- headers = origin && origin_allowed?(origin) ? cors_headers(origin) : {}
123
+ headers = origin ? cors_headers(origin) : {}
177
124
  [204, headers, []]
178
125
  end
179
126
 
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative '../util/host_guard'
5
+
6
+ module Woods
7
+ module MCP
8
+ # Immutable HTTP policy shared by Woods preflight and the MCP transport.
9
+ # Explicit cross-origin entries normalize HTTP(S) default ports. A portless entry also permits
10
+ # same-authority requests on another port, which the SDK accepts natively.
11
+ # No request header is rewritten and SDK rebinding protection stays enabled.
12
+ class OriginPolicy
13
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
14
+ DEFAULT_ORIGINS = %w[
15
+ http://localhost http://127.0.0.1 http://[::1]
16
+ https://localhost https://127.0.0.1 https://[::1]
17
+ ].freeze
18
+
19
+ # @param allowed_origins [Array<String>, nil] Explicit browser origins
20
+ def initialize(allowed_origins: nil)
21
+ entries = Array(allowed_origins).compact.reject do |entry|
22
+ entry.to_s.valid_encoding? && entry.to_s.strip.empty?
23
+ end
24
+ @explicit_origins = entries.map { |entry| configured_origin(entry) }.uniq.freeze
25
+ @allowed = (@explicit_origins.empty? ? DEFAULT_ORIGINS : @explicit_origins).freeze
26
+ @allowed_hosts = @explicit_origins.map do |origin|
27
+ host = authority(origin)
28
+ # SDK bare IPv6 host entries omit brackets; host:port entries retain
29
+ # them. URI#host alone retains brackets and loses explicit ports.
30
+ (host.end_with?(']') ? host[1...-1] : host).freeze
31
+ end.uniq.freeze
32
+ @transport_options = { allowed_origins: transport_origins, allowed_hosts: @allowed_hosts }.freeze
33
+ freeze
34
+ end
35
+
36
+ # @return [Hash] Constructor options supported by the MCP SDK
37
+ attr_reader :transport_options
38
+
39
+ # @param host [String, nil] Unmodified HTTP Host header
40
+ # @return [Boolean] Whether the request authority is allowed
41
+ def host_allowed?(host)
42
+ return true if host.nil?
43
+ return false unless host.is_a?(String) && host.valid_encoding?
44
+
45
+ parsed = parsed_origin("http://#{host}")
46
+ return false unless parsed
47
+
48
+ hostname = parsed.hostname.downcase
49
+ return false if Util::HostGuard.suspicious_numeric_host?(hostname)
50
+ return true if LOOPBACK_HOSTS.include?(hostname)
51
+
52
+ @allowed_hosts.include?(host.downcase) || @allowed_hosts.include?(hostname)
53
+ end
54
+
55
+ # @param origin [String, nil] Unmodified HTTP Origin header
56
+ # @param host [String, nil] Unmodified HTTP Host header
57
+ # @return [Boolean] Whether preflight and SDK dispatch can both accept it
58
+ def origin_allowed?(origin, host:)
59
+ return true if origin.nil?
60
+ return false unless parsed_origin(origin)
61
+
62
+ normalized = normalized_origin(origin)
63
+ return true if @explicit_origins.include?(normalized)
64
+ return false unless @allowed.include?(normalized) || @allowed.include?(normalized.sub(/:\d+\z/, ''))
65
+ return false unless host.is_a?(String) && host.valid_encoding?
66
+
67
+ # Matches the SDK's same-authority rule. The origin's scheme selects
68
+ # its default port; request.scheme is unreliable behind reverse proxies.
69
+ default_port = normalized.start_with?('https://') ? ':443' : ':80'
70
+ authority(normalized).delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
71
+ end
72
+
73
+ private
74
+
75
+ def configured_origin(entry)
76
+ raw = entry.to_s
77
+ normalized = raw.downcase.sub(%r{/\z}, '') if raw.valid_encoding?
78
+ unless parsed_origin(normalized)
79
+ label = raw.b.byteslice(0, 160).inspect
80
+ raise ArgumentError, "Invalid MCP allowed origin #{label}: expected http(s)://host[:port] without a path"
81
+ end
82
+
83
+ normalized_origin(normalized).freeze
84
+ end
85
+
86
+ def normalized_origin(origin)
87
+ value = origin.downcase
88
+ value.delete_suffix(value.start_with?('https://') ? ':443' : ':80')
89
+ end
90
+
91
+ # The SDK compares configured origins literally. Include equivalent
92
+ # default-port spellings without rewriting the incoming request headers.
93
+ def transport_origins
94
+ @explicit_origins.flat_map do |origin|
95
+ parsed = parsed_origin(origin)
96
+ default = parsed.scheme == 'https' ? 443 : 80
97
+ parsed.port == default ? [origin, "#{origin}:#{default}"] : [origin]
98
+ end.uniq.map(&:freeze).freeze
99
+ end
100
+
101
+ def authority(origin)
102
+ origin.sub(%r{\Ahttps?://}, '')
103
+ end
104
+
105
+ # Parse only serialized HTTP origins, never URLs with paths, userinfo,
106
+ # query strings, fragments or whitespace. The comparison layer handles
107
+ # default-port equivalence separately from parsing.
108
+ def parsed_origin(origin)
109
+ return unless origin.is_a?(String) && origin.valid_encoding? && origin.ascii_only?
110
+ return if origin.match?(/[[:space:][:cntrl:]]/)
111
+
112
+ parsed = URI.parse(origin)
113
+ return unless %w[http https].include?(parsed.scheme&.downcase)
114
+ return unless parsed.host && !parsed.host.empty?
115
+ return unless parsed.path.to_s.empty? && !parsed.userinfo && !parsed.query && !parsed.fragment
116
+ return unless parsed.port&.between?(1, 65_535)
117
+
118
+ parsed
119
+ rescue URI::InvalidURIError, ArgumentError
120
+ nil
121
+ end
122
+ end
123
+ end
124
+ end
@@ -12,6 +12,7 @@ require_relative '../atomic_file'
12
12
  require_relative '../generation'
13
13
  require_relative '../tasks'
14
14
  require_relative '../watch/status'
15
+ require_relative '../watch/supervision_status'
15
16
  require_relative '../filename_utils'
16
17
  require_relative '../update_check'
17
18
  require_relative '../retrieval/source_evidence'
@@ -928,6 +929,7 @@ module Woods
928
929
  coerce = method(:coerce_array)
929
930
  stale_check = method(:stale_index_result?)
930
931
  degraded_response = method(:degraded_retrieval_response)
932
+ corpus_status = method(:retriever_corpus_status)
931
933
  retrieval_mode = retriever.respond_to?(:mode) ? retriever.mode : :semantic
932
934
  server.define_tool(
933
935
  name: 'codebase_retrieve',
@@ -954,13 +956,13 @@ module Woods
954
956
  'rails_source, test_mapping, etc.). Overrides the default test_mapping exclusion. ' \
955
957
  'Lexical mode and explicit package/path scopes filter before limits and omit the global rank table. ' \
956
958
  'In semantic mode, when the unfiltered top-K has no requested type, the retriever ' \
957
- 'falls back to rank-within-type so the response is populated whenever units of ' \
958
- 'the requested type exist in the index. The response appends a "Type rank ' \
959
+ 'falls back to rank-within-type over the available vectors. The response appends a "Type rank ' \
959
960
  'context" table with per-type: source, rank in unfiltered top-K, global_k, ' \
960
- 'total_of_type. Read source to tell the cases apart: in_top_k (strong match), ' \
961
+ 'total_of_type (retrieval metadata records, not structural or embedded units). ' \
962
+ 'Read source to tell the cases apart: in_top_k (strong match), ' \
961
963
  'within_type_fallback (weak match surfaced by the fallback), outside_top_k ' \
962
- '(index has this type but other requested types filled the result), absent ' \
963
- '(zero units of this type in the index).'
964
+ '(retrieval metadata has this type but other requested types filled the result), absent ' \
965
+ '(zero retrieval metadata records of this type; structural units may still exist).'
964
966
  },
965
967
  packages: { type: 'array', items: { type: 'string' },
966
968
  description: 'Exact published nearest package owners. OR within the list; AND with paths and type eligibility.' },
@@ -1015,6 +1017,16 @@ module Woods
1015
1017
  )
1016
1018
  end
1017
1019
  if retriever
1020
+ corpus = corpus_status.call(retriever, include_types: false)
1021
+ if corpus && corpus[:state] == 'empty'
1022
+ next respond_err.call(
1023
+ 'Semantic retrieval has no vectors or retrieval metadata. The structural index may still be ready. ' \
1024
+ 'Run `woods:embed` in the application environment and reload the index, or use ' \
1025
+ 'explicit `WOODS_RETRIEVAL_MODE=lexical` and restart for retrieval without embeddings. ' \
1026
+ 'Use `search` and `lookup` for structural discovery.',
1027
+ code: :empty_index, tool: 'codebase_retrieve', corpus: corpus
1028
+ )
1029
+ end
1018
1030
  begin
1019
1031
  scope_options = if Retrieval::Scope.requested?(packages: packages, source_paths: source_paths)
1020
1032
  { packages: packages, source_paths: source_paths }
@@ -1138,12 +1150,15 @@ module Woods
1138
1150
 
1139
1151
  server.define_tool(
1140
1152
  name: 'trace_flow',
1141
- description: 'Trace execution flow from an entry point through the codebase',
1153
+ description: 'Trace a source-derived flow from an exact indexed unit, optionally scoped by #method. ' \
1154
+ 'Receiverless local calls may remain unexpanded; this is not proof of runtime execution.',
1142
1155
  input_schema: {
1143
1156
  properties: {
1144
1157
  entry_point: {
1145
1158
  type: 'string',
1146
- description: 'Entry point (e.g., UsersController#create)'
1159
+ description: 'Exact UnitIdentifier, optionally followed by #method (e.g., UsersController#create ' \
1160
+ 'or CheckoutService#order). Bare names identify units, including factories; ' \
1161
+ 'a bare method name does not locate its owning class. Use search and lookup first.'
1147
1162
  },
1148
1163
  depth: {
1149
1164
  type: 'integer',
@@ -2075,7 +2090,10 @@ module Woods
2075
2090
  name: 'woods_status',
2076
2091
  description: 'Diagnose whether the Woods index and server are healthy. Returns extraction metadata ' \
2077
2092
  '(last run, unit counts, git SHA, staleness in seconds), retriever/embedding configuration, ' \
2078
- 'bootstrap state (hydrated / degraded / failed + reason), feature flags, and a ready flag. ' \
2093
+ 'bootstrap state (hydrated / degraded / failed + reason), and feature flags. ' \
2094
+ 'Top-level `ready` describes structural index availability. ' \
2095
+ 'Semantic corpus diagnostics report local vector/metadata record counts separately; ' \
2096
+ 'unknown counts are null, and nonempty counts do not prove complete embedding coverage. ' \
2079
2097
  'Includes source-content freshness; quick scans have a 250ms budget, explicit deep scans have 5s. ' \
2080
2098
  'Incomplete evidence is unknown. Call this first on cold connect.',
2081
2099
  input_schema: { type: 'object', properties: {
@@ -2139,10 +2157,11 @@ module Woods
2139
2157
  },
2140
2158
  index: index_section(manifest, extracted_at, staleness, index_dir, reader, source_check),
2141
2159
  watch: watch_section(index_dir),
2160
+ supervision: index_dir ? Woods::Watch::SupervisionStatus.read(index_dir.to_s) : { records: [] },
2142
2161
  retriever: {
2143
2162
  configured: !retriever.nil?,
2144
2163
  class: retriever&.class&.name,
2145
- **(retriever.respond_to?(:mode) && retriever.mode == :lexical ? { mode: 'lexical' } : {})
2164
+ **retriever_status_fields(retriever)
2146
2165
  },
2147
2166
  bootstrap: bootstrap_state&.to_h,
2148
2167
  features: retrieval_features(config, resolved, retriever)
@@ -2151,6 +2170,19 @@ module Woods
2151
2170
 
2152
2171
  private
2153
2172
 
2173
+ def retriever_status_fields(retriever)
2174
+ return { mode: 'lexical' } if retriever.respond_to?(:mode) && retriever.mode == :lexical
2175
+
2176
+ corpus = retriever_corpus_status(retriever)
2177
+ corpus ? { corpus: corpus } : {}
2178
+ end
2179
+
2180
+ def retriever_corpus_status(retriever, include_types: true)
2181
+ return if retriever.respond_to?(:mode) && retriever.mode == :lexical
2182
+
2183
+ retriever.corpus_status(include_types: include_types) if retriever.respond_to?(:corpus_status)
2184
+ end
2185
+
2154
2186
  # Assemble the +index+ sub-hash of woods_status, including a staleness
2155
2187
  # gate that compares +manifest.git_sha+ against the current HEAD. The
2156
2188
  # manifest captures +git_sha+ / +gemfile_lock_sha+ / +schema_sha+ at
@@ -114,6 +114,7 @@ module Woods
114
114
  warn_late_console_path(config)
115
115
  return unless config.console_mcp_enabled && config.console_mcp_http_enabled
116
116
 
117
+ verify_console_origins!
117
118
  require 'woods/mcp/bearer_auth'
118
119
  token = config.console_mcp_token.to_s
119
120
  if token.empty?
@@ -147,6 +148,13 @@ module Woods
147
148
 
148
149
  private
149
150
 
151
+ def verify_console_origins!
152
+ require 'woods/mcp/origin_policy'
153
+ Woods::MCP::OriginPolicy.new(allowed_origins: config.console_mcp_allowed_origins)
154
+ rescue ArgumentError => e
155
+ raise Woods::ConfigurationError, "[Woods Console] #{e.message}"
156
+ end
157
+
150
158
  # Warn when the configured console path no longer matches the path the
151
159
  # stack was mounted at — the path was set after railtie initializers
152
160
  # captured the middleware arguments.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Retrieval
5
+ # Bounded, local diagnostics: never probe remote stores or providers.
6
+ # Nonempty counts establish presence, not alignment, completeness, or
7
+ # provider health. Metadata-only corpora can still serve non-vector paths.
8
+ module CorpusStatus
9
+ module_function
10
+
11
+ # @return [Hash] state and independently known local store statistics
12
+ def build(vector_store, metadata_store, include_types: true)
13
+ vectors = local_stats(vector_store, include_types: include_types)
14
+ metadata = local_stats(metadata_store, include_types: include_types)
15
+ { state: state(vectors[:count], metadata[:count]), vectors: vectors, metadata: metadata }
16
+ end
17
+
18
+ def local_stats(store, include_types:)
19
+ return unknown unless store.respond_to?(:local_corpus_stats)
20
+
21
+ stats = store.local_corpus_stats(include_types: include_types)
22
+ return unknown unless stats[:count].is_a?(Integer) && stats[:count] >= 0
23
+
24
+ stats
25
+ rescue StandardError, NotImplementedError
26
+ unknown
27
+ end
28
+ private_class_method :local_stats
29
+
30
+ def unknown
31
+ { count: nil, by_type: nil, untyped_count: nil }
32
+ end
33
+ private_class_method :unknown
34
+
35
+ def state(vectors, metadata)
36
+ return 'unknown' if vectors.nil? || metadata.nil?
37
+ return 'empty' if vectors.zero? && metadata.zero?
38
+ return 'metadata_only' if vectors.zero?
39
+ return 'vectors_only' if metadata.zero?
40
+
41
+ 'nonempty'
42
+ end
43
+ private_class_method :state
44
+ end
45
+ end
46
+ end