woods 2.0.0.beta2 → 2.0.0.beta3

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 (218) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +262 -1
  3. data/CONTRIBUTING.md +173 -9
  4. data/README.md +7 -3
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +83 -4
  7. data/docs/AGENT_SETUP.md +82 -1
  8. data/docs/BACKEND_MATRIX.md +20 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +199 -14
  11. data/docs/CONSOLE_MCP_SETUP.md +35 -5
  12. data/docs/DOCKER_SETUP.md +21 -2
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +36 -5
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +117 -1
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +221 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +33 -18
  22. data/docs/NOTION_INTEGRATION.md +13 -0
  23. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  24. data/docs/PUBLISHED_INDEX.md +55 -0
  25. data/docs/README.md +7 -0
  26. data/docs/RETRIEVAL_GUIDE.md +253 -11
  27. data/docs/RUNTIME_TRACING.md +71 -0
  28. data/docs/SOURCE_FRESHNESS.md +143 -0
  29. data/docs/TROUBLESHOOTING.md +117 -5
  30. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  31. data/docs/UPGRADING_TO_2.md +44 -22
  32. data/docs/WATCH_DAEMON.md +259 -59
  33. data/exe/woods-agent-config +6 -0
  34. data/exe/woods-extract +5 -0
  35. data/exe/woods-hook-context +6 -0
  36. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  37. data/lib/tasks/woods.rake +47 -397
  38. data/lib/woods/agent_configuration/applier.rb +133 -0
  39. data/lib/woods/agent_configuration/cli.rb +101 -0
  40. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  41. data/lib/woods/agent_configuration/document.rb +105 -0
  42. data/lib/woods/agent_configuration/error.rb +7 -0
  43. data/lib/woods/agent_configuration/launcher.rb +75 -0
  44. data/lib/woods/agent_configuration/layout.rb +59 -0
  45. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  46. data/lib/woods/agent_configuration/plan.rb +98 -0
  47. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  48. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  49. data/lib/woods/agent_configuration/planner.rb +63 -0
  50. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  51. data/lib/woods/agent_configuration/preflight.rb +100 -0
  52. data/lib/woods/agent_configuration/recovery.rb +49 -0
  53. data/lib/woods/ast/node.rb +2 -0
  54. data/lib/woods/ast/parser.rb +38 -5
  55. data/lib/woods/builder.rb +21 -5
  56. data/lib/woods/cache/cache_middleware.rb +28 -7
  57. data/lib/woods/cache/cache_store.rb +4 -5
  58. data/lib/woods/change_set.rb +5 -4
  59. data/lib/woods/console/credential_index.rb +20 -2
  60. data/lib/woods/console/credential_scanner.rb +14 -14
  61. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  62. data/lib/woods/console/embedded_executor.rb +1 -1
  63. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  64. data/lib/woods/console/rack_middleware.rb +22 -13
  65. data/lib/woods/console/server.rb +18 -16
  66. data/lib/woods/dependency_graph.rb +65 -13
  67. data/lib/woods/embedding/corpus.rb +94 -0
  68. data/lib/woods/embedding/indexer.rb +90 -46
  69. data/lib/woods/embedding/openai.rb +17 -6
  70. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  71. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  72. data/lib/woods/export/typed_reader.rb +56 -0
  73. data/lib/woods/extractor.rb +232 -137
  74. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  75. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  76. data/lib/woods/extractors/caching_extractor.rb +3 -1
  77. data/lib/woods/extractors/concern_extractor.rb +64 -6
  78. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  79. data/lib/woods/extractors/controller_extractor.rb +13 -4
  80. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  81. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  82. data/lib/woods/extractors/engine_extractor.rb +3 -1
  83. data/lib/woods/extractors/event_extractor.rb +4 -2
  84. data/lib/woods/extractors/factory_extractor.rb +3 -1
  85. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  86. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  87. data/lib/woods/extractors/job_extractor.rb +6 -19
  88. data/lib/woods/extractors/lib_extractor.rb +3 -1
  89. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  90. data/lib/woods/extractors/manager_extractor.rb +3 -1
  91. data/lib/woods/extractors/method_parameters.rb +53 -0
  92. data/lib/woods/extractors/middleware_argument.rb +65 -0
  93. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  94. data/lib/woods/extractors/migration_extractor.rb +3 -1
  95. data/lib/woods/extractors/model_extractor.rb +39 -33
  96. data/lib/woods/extractors/package_extractor.rb +24 -4
  97. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  98. data/lib/woods/extractors/policy_extractor.rb +3 -1
  99. data/lib/woods/extractors/poro_extractor.rb +3 -1
  100. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  101. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  102. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  103. data/lib/woods/extractors/route_extractor.rb +3 -1
  104. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  105. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  106. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  107. data/lib/woods/extractors/service_extractor.rb +3 -1
  108. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  109. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  110. data/lib/woods/extractors/source_nesting.rb +1 -1
  111. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  112. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  113. data/lib/woods/extractors/validator_extractor.rb +3 -1
  114. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  116. data/lib/woods/gem_mapper.rb +2 -0
  117. data/lib/woods/git_history.rb +116 -0
  118. data/lib/woods/graph_analyzer.rb +35 -6
  119. data/lib/woods/hooks/context_cli.rb +54 -0
  120. data/lib/woods/hooks/context_event.rb +88 -0
  121. data/lib/woods/hooks/context_hint.rb +73 -0
  122. data/lib/woods/hooks/context_impact.rb +77 -0
  123. data/lib/woods/hooks/context_output.rb +47 -0
  124. data/lib/woods/hooks/context_state.rb +102 -0
  125. data/lib/woods/hooks/refresh.rb +79 -0
  126. data/lib/woods/hooks/rule_projection.rb +78 -0
  127. data/lib/woods/input_rules.rb +19 -0
  128. data/lib/woods/mcp/bearer_auth.rb +20 -12
  129. data/lib/woods/mcp/bootstrapper.rb +62 -0
  130. data/lib/woods/mcp/index_reader.rb +323 -160
  131. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  132. data/lib/woods/mcp/origin_guard.rb +17 -9
  133. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  134. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  135. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  136. data/lib/woods/mcp/search_results.rb +74 -0
  137. data/lib/woods/mcp/server.rb +158 -37
  138. data/lib/woods/mcp/tool_contract.rb +2 -0
  139. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  140. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  141. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  142. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  143. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  144. data/lib/woods/notion/exporter.rb +56 -17
  145. data/lib/woods/obsidian/destination_plan.rb +98 -0
  146. data/lib/woods/obsidian/name_mapper.rb +19 -3
  147. data/lib/woods/obsidian/note_builder.rb +19 -10
  148. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  149. data/lib/woods/operator/pipeline_guard.rb +18 -13
  150. data/lib/woods/path_dispatcher.rb +7 -1
  151. data/lib/woods/payload_store.rb +27 -26
  152. data/lib/woods/railtie.rb +3 -3
  153. data/lib/woods/railtie_support.rb +12 -12
  154. data/lib/woods/rake_helpers.rb +392 -0
  155. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  156. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  157. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  158. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  159. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  160. data/lib/woods/resilience/index_validator.rb +112 -23
  161. data/lib/woods/retrieval/context_assembler.rb +50 -15
  162. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  163. data/lib/woods/retrieval/lexical_index.rb +119 -0
  164. data/lib/woods/retrieval/ranker.rb +4 -2
  165. data/lib/woods/retrieval/scope.rb +108 -0
  166. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  167. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  168. data/lib/woods/retrieval/search_executor.rb +86 -27
  169. data/lib/woods/retrieval/source_evidence.rb +200 -0
  170. data/lib/woods/retriever.rb +98 -22
  171. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  172. data/lib/woods/session_tracer/middleware.rb +10 -12
  173. data/lib/woods/session_tracer/redis_store.rb +22 -6
  174. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  175. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  176. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  177. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  178. data/lib/woods/source_inputs/handoff.rb +102 -0
  179. data/lib/woods/source_inputs/launcher.rb +157 -0
  180. data/lib/woods/source_inputs/manifest.rb +124 -0
  181. data/lib/woods/source_inputs/private_key.rb +55 -0
  182. data/lib/woods/source_inputs/scanner.rb +171 -0
  183. data/lib/woods/source_inputs/scopes.rb +71 -0
  184. data/lib/woods/source_inputs/session.rb +214 -0
  185. data/lib/woods/source_inputs/status.rb +84 -0
  186. data/lib/woods/source_inputs/verifier.rb +107 -0
  187. data/lib/woods/storage/metadata_store.rb +25 -25
  188. data/lib/woods/storage/pgvector.rb +29 -8
  189. data/lib/woods/storage/qdrant.rb +17 -7
  190. data/lib/woods/storage/vector_store.rb +18 -6
  191. data/lib/woods/tasks.rb +3 -2
  192. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  193. data/lib/woods/unblocked/exporter.rb +59 -70
  194. data/lib/woods/version.rb +1 -1
  195. data/lib/woods/watch/boot_snapshot.rb +52 -0
  196. data/lib/woods/watch/daemon.rb +136 -28
  197. data/lib/woods/watch/listen_watcher.rb +4 -0
  198. data/lib/woods/watch/polling_watcher.rb +5 -1
  199. data/lib/woods/watch/status.rb +20 -15
  200. data/lib/woods/watch/tree_scan.rb +21 -13
  201. data/lib/woods/watch/watcher.rb +4 -1
  202. data/lib/woods.rb +50 -11
  203. data/plugin/.claude-plugin/plugin.json +1 -1
  204. data/plugin/hooks/adapters/normalize.jq +15 -0
  205. data/plugin/hooks/adapters/normalize.rb +63 -0
  206. data/plugin/hooks/hooks.json +20 -0
  207. data/plugin/hooks/woods-context.sh +50 -0
  208. data/plugin/hooks/woods-input-rules.sh +159 -0
  209. data/plugin/hooks/woods-opencode.mjs +65 -0
  210. data/plugin/hooks/woods-post-edit.sh +2 -225
  211. data/plugin/hooks/woods-refresh.sh +260 -0
  212. data/plugin/hooks/woods-session-start.sh +47 -55
  213. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  214. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  215. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  216. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  217. data/plugin/skills/woods-setup/SKILL.md +107 -6
  218. metadata +84 -5
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Hooks
5
+ # Resolve one unambiguous edited identity, then reuse the bounded graph walk.
6
+ class ContextImpact
7
+ MAX_NODES = 50_000
8
+
9
+ def initialize(reader, renderer)
10
+ @reader = reader
11
+ @renderer = renderer
12
+ end
13
+
14
+ def call(path, generation, freshness)
15
+ graph = @reader.raw_graph_data
16
+ roots = roots_for(graph, path)
17
+ header = "Woods candidates from generation #{generation} (pre-refresh snapshot; " \
18
+ "source freshness: #{freshness}). Changed path: #{JSON.generate(path)}."
19
+ return unresolved(header, roots) unless roots.size == 1
20
+
21
+ root = roots.first
22
+ traversal = @reader.traverse_dependents(root.fetch('identifier'), depth: 2, explain: true, max_nodes: 10,
23
+ max_edges: 100)
24
+ @renderer.context(header, rows_for(traversal), partial: !!traversal[:partial])
25
+ end
26
+
27
+ private
28
+
29
+ def rows_for(traversal)
30
+ rows = traversal.fetch(:explanation).fetch(:witnesses).filter_map do |_identifier, witness|
31
+ next if witness[:impact] == 'root'
32
+
33
+ edge = traversal[:explanation][:edges].fetch(witness.fetch(:edge_id))
34
+ evidence_row(edge, witness)
35
+ end
36
+ rows << 'No candidate found within this bounded snapshot; verify manually with dependents.' if rows.empty?
37
+ rows
38
+ end
39
+
40
+ def roots_for(graph, path)
41
+ records = records_for(graph)
42
+ matching = records.select { |node| node['file_path'] == path }
43
+ identities = matching.map { |node| node.slice('identifier', 'type') }.uniq
44
+ return identities if identities.size != 1
45
+
46
+ # A recorded edge target does not identify which same-named type it means.
47
+ records.select { |node| node['identifier'] == identities.first['identifier'] }
48
+ .map { |node| node.slice('identifier', 'type') }.uniq
49
+ end
50
+
51
+ def unresolved(header, roots)
52
+ reason = roots.empty? ? 'unresolved changed path' : 'ambiguous changed identity'
53
+ rows = roots.first(10).map { |root| "#{root['identifier']} (#{root['type']})" }
54
+ @renderer.context("#{header} #{reason}; verify with search and typed lookup.", rows, partial: true)
55
+ end
56
+
57
+ def evidence_row(edge, witness)
58
+ source = edge.fetch(:source)
59
+ target = edge.fetch(:target)
60
+ target_type = target[:type] || "ambiguous/unknown #{Array(target[:candidate_types]).join(',')}"
61
+ test_hint = source[:type] == 'test_mapping' ? '; test suggestion' : ''
62
+ uncertain = witness[:typed_path_complete] ? '' : '; typed path incomplete'
63
+ "#{witness[:impact]} candidate: #{source[:identifier]} (#{source[:type]}) " \
64
+ "--via=#{edge[:via] || 'unknown'}--> #{target[:identifier]} (#{target_type})#{test_hint}#{uncertain}"
65
+ end
66
+
67
+ def records_for(graph)
68
+ nodes = graph.fetch('nodes')
69
+ variants = graph.fetch('variants', [])
70
+ raise ArgumentError unless nodes.is_a?(Hash) && variants.is_a?(Array)
71
+ raise ArgumentError if nodes.size + variants.size > MAX_NODES
72
+
73
+ nodes.map { |identifier, node| node.merge('identifier' => identifier) } + variants
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Woods
6
+ module Hooks
7
+ # Bounds the complete client JSON envelope, preserving whole evidence rows.
8
+ class ContextOutput
9
+ MAX_BYTES = 2048
10
+ UNAVAILABLE = 'Woods context unavailable or incomplete; inspect woods_status ' \
11
+ 'and verify with search/lookup/dependents.'
12
+
13
+ def initialize(kind)
14
+ @kind = kind
15
+ end
16
+
17
+ def context(header, rows = [], partial: false)
18
+ selected = []
19
+ rows.each do |row|
20
+ if envelope(([header] + selected + [row, footer(true)]).join("\n")).bytesize > MAX_BYTES
21
+ partial = true
22
+ break
23
+ end
24
+ selected << row
25
+ end
26
+ text = ([header] + selected + [footer(partial)]).join("\n")
27
+ envelope(text).bytesize <= MAX_BYTES ? text : UNAVAILABLE
28
+ end
29
+
30
+ def encode(context)
31
+ result = envelope(context)
32
+ result.bytesize <= MAX_BYTES ? result : envelope(UNAVAILABLE)
33
+ end
34
+
35
+ private
36
+
37
+ def footer(partial)
38
+ "truncated: #{partial ? 'yes' : 'no'}; depth<=2, nodes<=10, edges<=100. " \
39
+ 'Verify with dependents explain:true; candidates and test suggestions are not proof.'
40
+ end
41
+
42
+ def envelope(context)
43
+ JSON.generate(hookSpecificOutput: { hookEventName: @kind, additionalContext: context })
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'securerandom'
6
+
7
+ module Woods
8
+ module Hooks
9
+ # Separate, bounded emitted-hint history; never touches refresh queue/leases.
10
+ class ContextState
11
+ MAX_BYTES = 131_072
12
+ MAX_SESSIONS = 32
13
+ MAX_HINTS = 32
14
+
15
+ def initialize(output_dir)
16
+ @file = File.join(output_dir, 'hook-context-state.json')
17
+ end
18
+
19
+ def emit(result)
20
+ return yield unless suppressible?(result)
21
+
22
+ key = Digest::SHA256.hexdigest(JSON.generate([result[:root], result[:session]]))
23
+ open_regular("#{@file}.lock", File::RDWR | File::CREAT) do |lock|
24
+ return unless lock.flock(File::LOCK_EX | File::LOCK_NB)
25
+
26
+ state = read_state
27
+ history = state.delete(key) || []
28
+ return if history.include?(result[:identity])
29
+
30
+ yield
31
+ state[key] = (history + [result[:identity]]).last(MAX_HINTS)
32
+ state.shift while state.size > MAX_SESSIONS
33
+ write_state(state)
34
+ end
35
+ rescue SystemCallError, IOError
36
+ # State contention/unavailability may skip an optional hint, never refresh.
37
+ nil
38
+ end
39
+
40
+ private
41
+
42
+ def suppressible?(result)
43
+ result[:identity] && result[:session]
44
+ end
45
+
46
+ def open_regular(path, flags)
47
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
48
+ File.open(path, flags | File::NONBLOCK, 0o600) do |file|
49
+ raise IOError unless file.stat.file?
50
+
51
+ yield file
52
+ end
53
+ end
54
+
55
+ def read_state
56
+ open_regular(@file, File::RDONLY) do |file|
57
+ return {} if file.stat.size > MAX_BYTES
58
+
59
+ state = JSON.parse(file.read(MAX_BYTES + 1))
60
+ return {} unless valid_state?(state)
61
+
62
+ state
63
+ end
64
+ rescue Errno::ENOENT, JSON::ParserError
65
+ {}
66
+ end
67
+
68
+ def valid_state?(state)
69
+ state.is_a?(Hash) && state.size <= MAX_SESSIONS && state.all? do |key, history|
70
+ digest?(key) && valid_history?(history)
71
+ end
72
+ end
73
+
74
+ def valid_history?(history)
75
+ history.is_a?(Array) && history.size <= MAX_HINTS && history.all? { |id| digest?(id) }
76
+ end
77
+
78
+ def digest?(value)
79
+ value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
80
+ end
81
+
82
+ def write_state(state)
83
+ temporary = "#{@file}.tmp-#{SecureRandom.hex(12)}"
84
+ owned = nil
85
+ open_regular(temporary, File::WRONLY | File::CREAT | File::EXCL) do |file|
86
+ owned = file.stat
87
+ file.write(JSON.generate(state))
88
+ end
89
+ File.rename(temporary, @file)
90
+ ensure
91
+ remove_owned_temporary(temporary, owned) if owned
92
+ end
93
+
94
+ def remove_owned_temporary(path, owned)
95
+ current = File.lstat(path)
96
+ File.unlink(path) if current.dev == owned.dev && current.ino == owned.ino
97
+ rescue Errno::ENOENT
98
+ nil
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+ require 'woods/input_rules'
6
+ require 'woods/rake_helpers'
7
+
8
+ module Woods
9
+ module Hooks
10
+ # The JSON argument is portable across host/container command boundaries.
11
+ # Exit 75 explicitly defers work; only successful consumption acknowledges it.
12
+ class Refresh
13
+ MAX_BYTES = 128 * 1024
14
+ MAX_EVENTS = 1000
15
+ OPERATIONS = %w[add update delete move].freeze
16
+
17
+ def initialize(encoded)
18
+ raise ArgumentError, 'hook batch too large' if encoded.to_s.bytesize > MAX_BYTES * 2
19
+
20
+ @batch = JSON.parse(Base64.strict_decode64(encoded.to_s))
21
+ validate!
22
+ end
23
+
24
+ def call
25
+ output = File.expand_path(@batch.fetch('output'), RakeHelpers.woods_task_root)
26
+ if RakeHelpers.woods_daemon_coverage(output) == :running
27
+ warn 'Woods hook deferred: watch daemon is active; this batch has not been acknowledged.'
28
+ exit 75
29
+ end
30
+
31
+ Rake::Task[:environment].invoke
32
+ require 'woods/extractor'
33
+ actions, paths = extraction_work
34
+ return if paths.empty?
35
+
36
+ extractor = Extractor.new(output_dir: output)
37
+ RakeHelpers.woods_with_extraction_lock(output) do
38
+ actions.include?(:full) ? extractor.extract_all : extractor.extract_changed(paths)
39
+ extractor.raise_on_publication_failure!
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def extraction_work
46
+ rules = InputRules.new
47
+ events = @batch.fetch('events')
48
+ actions = events.map { |event| rules.action(event.fetch('path'), operation: event.fetch('operation')) }
49
+ paths = events.zip(actions).filter_map { |event, action| event.fetch('path') unless action == :ignore }.uniq
50
+ [actions, paths]
51
+ end
52
+
53
+ def validate!
54
+ raise ArgumentError, 'unsupported hook batch' unless @batch.is_a?(Hash) && @batch['version'] == 1
55
+ raise ArgumentError, 'invalid hook output' unless valid_string?(@batch['output'])
56
+
57
+ events = @batch['events']
58
+ raise ArgumentError, 'invalid hook events' unless valid_events?(events)
59
+ end
60
+
61
+ def valid_events?(events)
62
+ events.is_a?(Array) && events.size.between?(1, MAX_EVENTS) && events.all? { |event| valid_event?(event) }
63
+ end
64
+
65
+ def valid_event?(event)
66
+ return false unless event.is_a?(Hash) && OPERATIONS.include?(event['operation'])
67
+
68
+ path = event['path']
69
+ valid_string?(path) && !path.start_with?('/') && path.split('/', -1).none? do |part|
70
+ ['', '.', '..'].include?(part)
71
+ end
72
+ end
73
+
74
+ def valid_string?(value)
75
+ value.is_a?(String) && !value.empty? && !value.include?("\0") && value.bytesize <= MAX_BYTES
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require 'woods/input_rules'
5
+
6
+ module Woods
7
+ module Hooks
8
+ # Generated Bash predicate: the plugin also runs on hosts with no Ruby/bundle.
9
+ # This is a projection of authoritative rules, never a second path inventory.
10
+ class RuleProjection
11
+ def render
12
+ lines = [
13
+ '# Generated by script/generate-hook-rules; do not edit.',
14
+ 'woods_input_action() {',
15
+ ' local path="$1" operation="${2:-update}"'
16
+ ]
17
+ restart_conditions.each { |condition| lines << " if #{condition}; then printf full; return; fi" }
18
+ rules.each do |rule|
19
+ lines << " if #{condition_for(rule)}; then"
20
+ lines << ' case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac'
21
+ lines << ' return'
22
+ lines << ' fi'
23
+ end
24
+ lines.push(' printf ignore', '}', '').join("\n")
25
+ end
26
+
27
+ private
28
+
29
+ def rules
30
+ PathDispatcher.runtime_rules +
31
+ PathDispatcher.file_rules + PathDispatcher.whole_app_rules
32
+ end
33
+
34
+ def restart_conditions
35
+ policy = ReloadPolicy
36
+ conditions = policy::RESTART_PATHS.map { |path| "[[ \"$path\" == #{quote(path)} ]]" }
37
+ conditions += policy::RESTART_DIRECTORIES.map { |dir| "[[ \"$path\" == #{quote("#{dir}/")}* ]]" }
38
+ conditions + policy::RESTART_PATH_PATTERNS.map do |pattern|
39
+ regex = pattern.source.gsub('\\A', '^').gsub('\\z', '$')
40
+ "[[ \"$path\" =~ #{regex} ]]"
41
+ end
42
+ end
43
+
44
+ def condition_for(rule)
45
+ alternatives = rule.exact_paths.to_a.map { |path| "[[ \"$path\" == #{quote(path)} ]]" }
46
+ filters = rule.exclude.to_a.map { |part| "[[ \"$path\" != *#{quote(part)}* ]]" }
47
+ filters += if rule.basenames
48
+ [either(rule.basenames.map { |name| "[[ \"${path##*/}\" == #{quote(name)} ]]" })]
49
+ else
50
+ directory_filters(rule)
51
+ end
52
+ alternatives << "{ #{filters.join(' && ')}; }"
53
+ either(alternatives)
54
+ end
55
+
56
+ def directory_filters(rule)
57
+ filters = []
58
+ filters << either(rule.extensions.map { |ext| "[[ \"$path\" == *#{quote(ext)} ]]" }) if rule.extensions
59
+ filters << "[[ \"$path\" == *#{quote(rule.require_segment)}* ]]" if rule.require_segment
60
+ filters << either(rule.dirs.to_a.map { |dir| directory_condition(dir, rule.recursive) })
61
+ end
62
+
63
+ def directory_condition(dir, recursive)
64
+ variable = recursive == false ? '${path%/*}' : '$path'
65
+ suffix = recursive == false ? quote(dir) : "#{quote("#{dir}/")}*"
66
+ "[[ \"#{variable}\" == #{suffix} ]]"
67
+ end
68
+
69
+ def either(conditions)
70
+ conditions.empty? ? 'false' : "{ #{conditions.join(' || ')}; }"
71
+ end
72
+
73
+ def quote(value)
74
+ Shellwords.escape(value)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'woods/reload_policy'
4
+ require 'woods/path_dispatcher'
5
+
6
+ module Woods
7
+ # Shared fresh-process action contract. Loading this class does not boot Rails.
8
+ # Extractor constants are needed only when deriving the dispatcher projection.
9
+ class InputRules
10
+ def action(path, operation: 'update')
11
+ return :full if ReloadPolicy.new.classify(path) == :restart
12
+ return :ignore unless PathDispatcher.new.relevant?(path)
13
+
14
+ # A removed runtime class may no longer be discoverable in a fresh boot.
15
+ # Full extraction also handles the old side of a rename conservatively.
16
+ %w[delete move].include?(operation) ? :full : :incremental
17
+ end
18
+ end
19
+ end
@@ -35,19 +35,12 @@ module Woods
35
35
  # @param enabled [#call, nil] Optional request-time predicate. When it
36
36
  # returns falsy the request passes through unguarded. Nil (the
37
37
  # default) means always guard.
38
- def initialize(app, token:, path: nil, enabled: nil)
39
- @app = app
40
- @path = path
41
- @enabled = enabled
42
- @warned_unusable_token = false
38
+ # Rails 6.0 forwards middleware options as a positional hash on Ruby 3.
39
+ # Delegate to explicit keywords to preserve required/unknown option checks.
40
+ def initialize(app, options = {}, **keywords)
41
+ raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
43
42
 
44
- if token.respond_to?(:call)
45
- @token_source = token
46
- else
47
- validate_static_token!(token)
48
- static_token = token.to_s
49
- @token_source = -> { static_token }
50
- end
43
+ initialize_options(app, **options, **keywords)
51
44
  end
52
45
 
53
46
  # Rack entry point. Out-of-scope requests (non-matching `path:` prefix
@@ -72,6 +65,21 @@ module Woods
72
65
 
73
66
  private
74
67
 
68
+ def initialize_options(app, token:, path: nil, enabled: nil)
69
+ @app = app
70
+ @path = path
71
+ @enabled = enabled
72
+ @warned_unusable_token = false
73
+
74
+ if token.respond_to?(:call)
75
+ @token_source = token
76
+ else
77
+ validate_static_token!(token)
78
+ static_token = token.to_s
79
+ @token_source = -> { static_token }
80
+ end
81
+ end
82
+
75
83
  # @param env [Hash] Rack environment
76
84
  # @return [Boolean] whether this request falls under the guard
77
85
  def guard?(env)
@@ -6,6 +6,7 @@ require_relative 'errors'
6
6
  require_relative 'bootstrap_state'
7
7
  require_relative 'config_resolver'
8
8
  require_relative 'provider_probe'
9
+ require_relative 'published_lexical_retriever'
9
10
  require_relative '../index_artifact'
10
11
  require_relative '../builder'
11
12
  require_relative '../resolved_config'
@@ -128,6 +129,12 @@ module Woods
128
129
  # credentials, dimension mismatch, unsupported artifact, or a missing
129
130
  # artifact under WOODS_REQUIRE_INDEX=1).
130
131
  def self.build_retriever(index_dir: nil)
132
+ mode = ENV.fetch('WOODS_RETRIEVAL_MODE', Woods.configuration.retrieval_mode.to_s)
133
+ unless %w[semantic lexical].include?(mode)
134
+ raise BootstrapError, "WOODS_RETRIEVAL_MODE must be semantic or lexical, got #{mode.inspect}"
135
+ end
136
+ return build_lexical_retriever(index_dir) if mode == 'lexical'
137
+
131
138
  state = BootstrapState.new
132
139
  state.mark(:hydrating)
133
140
 
@@ -161,6 +168,20 @@ module Woods
161
168
  [retriever, state]
162
169
  end
163
170
 
171
+ def self.build_lexical_retriever(index_dir)
172
+ state = BootstrapState.new
173
+ state.mark(:hydrating)
174
+ retriever = PublishedLexicalRetriever.new(index_dir: index_dir || Woods.configuration.output_dir,
175
+ default_budget: Woods.configuration.max_context_tokens)
176
+ retriever.warmup!
177
+ state.mark(:hydrated)
178
+ warn '[woods-mcp] lexical retrieval: hydrated (published extraction units; no embeddings)'
179
+ [retriever, state]
180
+ rescue StandardError => e
181
+ raise BootstrapError, "lexical index could not be loaded: #{e.class}: #{e.message}"
182
+ end
183
+ private_class_method :build_lexical_retriever
184
+
164
185
  def self.static_source_map_without_embeddings?(artifact)
165
186
  return false unless artifact
166
187
 
@@ -248,6 +269,10 @@ module Woods
248
269
  zero_counts = { vectors: 0, metadata: 0, graph: 0 }
249
270
  return refresh_reader_only(reader, zero_counts) unless retriever
250
271
 
272
+ if retriever.is_a?(PublishedLexicalRetriever)
273
+ return reload_lexical_stores!(retriever, index_dir, reader || retriever.reader, state, hooks)
274
+ end
275
+
251
276
  target = swap_target(retriever)
252
277
  return refresh_reader_only(reader, zero_counts) unless target
253
278
 
@@ -312,6 +337,43 @@ module Woods
312
337
  # Refresh the reader's cached index state and answer the zero-count
313
338
  # no-op (MCP-2).
314
339
  #
340
+ def self.reload_lexical_stores!(retriever, index_dir, reader, state, hooks)
341
+ generation = Woods::Generation.new(output_dir: index_dir)
342
+ captured = generation.current
343
+ candidate = PublishedLexicalRetriever.new(index_dir: index_dir).warmup!
344
+ run_hook(hooks, :after_candidates)
345
+ lock = reload_extraction_lock(index_dir)
346
+ unless acquire_writer_lock_briefly(lock)
347
+ raise ReloadDegraded.new('lexical reload could not acquire the extraction writer lock',
348
+ generation: reader.loaded_generation || 0, stores: ['metadata'])
349
+ end
350
+
351
+ begin
352
+ reader.with_exclusive_generation do
353
+ identity = candidate.snapshot.first
354
+ expected = [captured.number, captured.token, generation.payload_dir(captured).to_s]
355
+ unless same_generation_marker?(captured, generation.current) &&
356
+ [identity.first.to_i, *identity.drop(1)] == expected
357
+ raise ReloadGenerationMoved.new('generation moved during lexical reload; nothing was swapped',
358
+ generation: reader.loaded_generation || 0, stores: ['metadata'])
359
+ end
360
+ counts = { vectors: 0, metadata: candidate.metadata_store.count, graph: 0 }
361
+ reader.reload!
362
+ retriever.install_snapshot!(candidate.snapshot)
363
+ state&.clear_reload_failure!
364
+ counts
365
+ end
366
+ ensure
367
+ lock.release
368
+ end
369
+ rescue ReloadDegraded
370
+ raise
371
+ rescue StandardError => e
372
+ raise ReloadDegraded.new("lexical snapshot reload failed: #{e.class}: #{e.message}",
373
+ generation: reader.loaded_generation || 0, stores: ['metadata'])
374
+ end
375
+ private_class_method :reload_lexical_stores!
376
+
315
377
  # A store no-op is not an index no-op: the caller invoked `reload`
316
378
  # because something on disk moved, and on a flat (pre-2.0) index
317
379
  # {IndexReader} never self-refreshes, so skipping this reports