woods 2.0.0.beta2 → 2.0.0.beta4

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +339 -1
  3. data/CONTRIBUTING.md +188 -12
  4. data/README.md +93 -174
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +109 -8
  7. data/docs/AGENT_SETUP.md +98 -7
  8. data/docs/BACKEND_MATRIX.md +25 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +267 -16
  11. data/docs/CONSOLE_MCP_SETUP.md +80 -7
  12. data/docs/DOCKER_SETUP.md +22 -3
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +45 -6
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +147 -7
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +276 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +37 -22
  22. data/docs/MCP_WORKTREE_SETUP.md +43 -83
  23. data/docs/NOTION_INTEGRATION.md +13 -0
  24. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  25. data/docs/PUBLISHED_INDEX.md +72 -0
  26. data/docs/README.md +7 -0
  27. data/docs/RETRIEVAL_GUIDE.md +273 -12
  28. data/docs/RUNTIME_TRACING.md +71 -0
  29. data/docs/SOURCE_FRESHNESS.md +143 -0
  30. data/docs/TROUBLESHOOTING.md +129 -18
  31. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  32. data/docs/UPGRADING_TO_2.md +48 -22
  33. data/docs/WATCH_DAEMON.md +277 -67
  34. data/exe/woods-agent-config +6 -0
  35. data/exe/woods-extract +5 -0
  36. data/exe/woods-hook-context +6 -0
  37. data/exe/woods-mcp-start +14 -9
  38. data/lib/generators/woods/pgvector_generator.rb +8 -2
  39. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  40. data/lib/tasks/woods.rake +47 -397
  41. data/lib/woods/agent_configuration/applier.rb +135 -0
  42. data/lib/woods/agent_configuration/cli.rb +101 -0
  43. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  44. data/lib/woods/agent_configuration/document.rb +105 -0
  45. data/lib/woods/agent_configuration/error.rb +7 -0
  46. data/lib/woods/agent_configuration/launcher.rb +75 -0
  47. data/lib/woods/agent_configuration/layout.rb +72 -0
  48. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  49. data/lib/woods/agent_configuration/plan.rb +98 -0
  50. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  51. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  52. data/lib/woods/agent_configuration/planner.rb +63 -0
  53. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  54. data/lib/woods/agent_configuration/preflight.rb +100 -0
  55. data/lib/woods/agent_configuration/recovery.rb +49 -0
  56. data/lib/woods/ast/node.rb +2 -0
  57. data/lib/woods/ast/parser.rb +38 -5
  58. data/lib/woods/builder.rb +21 -5
  59. data/lib/woods/cache/cache_middleware.rb +28 -7
  60. data/lib/woods/cache/cache_store.rb +4 -5
  61. data/lib/woods/change_set.rb +5 -4
  62. data/lib/woods/console/credential_index.rb +20 -2
  63. data/lib/woods/console/credential_scanner.rb +18 -17
  64. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  65. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  66. data/lib/woods/console/embedded_executor.rb +32 -10
  67. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  68. data/lib/woods/console/rack_middleware.rb +22 -13
  69. data/lib/woods/console/server.rb +18 -16
  70. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  71. data/lib/woods/console/sql_table_scanner.rb +47 -7
  72. data/lib/woods/console/sql_validator.rb +49 -9
  73. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  74. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  75. data/lib/woods/dependency_graph.rb +65 -13
  76. data/lib/woods/embedding/corpus.rb +94 -0
  77. data/lib/woods/embedding/indexer.rb +114 -60
  78. data/lib/woods/embedding/openai.rb +17 -6
  79. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  80. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  81. data/lib/woods/export/typed_reader.rb +56 -0
  82. data/lib/woods/extractor.rb +277 -149
  83. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  84. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  85. data/lib/woods/extractors/caching_extractor.rb +3 -1
  86. data/lib/woods/extractors/concern_extractor.rb +64 -6
  87. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  88. data/lib/woods/extractors/controller_extractor.rb +13 -4
  89. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  90. data/lib/woods/extractors/declared_parent.rb +55 -0
  91. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  92. data/lib/woods/extractors/engine_extractor.rb +3 -1
  93. data/lib/woods/extractors/event_extractor.rb +4 -2
  94. data/lib/woods/extractors/factory_extractor.rb +3 -1
  95. data/lib/woods/extractors/graphql_extractor.rb +10 -13
  96. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  97. data/lib/woods/extractors/job_extractor.rb +6 -19
  98. data/lib/woods/extractors/lib_extractor.rb +13 -9
  99. data/lib/woods/extractors/mailer_extractor.rb +26 -15
  100. data/lib/woods/extractors/manager_extractor.rb +3 -1
  101. data/lib/woods/extractors/method_parameters.rb +53 -0
  102. data/lib/woods/extractors/middleware_argument.rb +65 -0
  103. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  104. data/lib/woods/extractors/migration_extractor.rb +3 -1
  105. data/lib/woods/extractors/model_extractor.rb +26 -34
  106. data/lib/woods/extractors/package_extractor.rb +24 -4
  107. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  108. data/lib/woods/extractors/policy_extractor.rb +3 -1
  109. data/lib/woods/extractors/poro_extractor.rb +13 -9
  110. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  111. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  112. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  113. data/lib/woods/extractors/route_extractor.rb +3 -1
  114. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  115. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  116. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  117. data/lib/woods/extractors/service_extractor.rb +3 -1
  118. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  119. data/lib/woods/extractors/shared_utility_methods.rb +48 -19
  120. data/lib/woods/extractors/source_nesting.rb +1 -1
  121. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  122. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  123. data/lib/woods/extractors/validator_extractor.rb +3 -1
  124. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  125. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  126. data/lib/woods/gem_mapper.rb +2 -0
  127. data/lib/woods/git_history.rb +116 -0
  128. data/lib/woods/graph_analyzer.rb +35 -6
  129. data/lib/woods/hooks/context_cli.rb +54 -0
  130. data/lib/woods/hooks/context_event.rb +88 -0
  131. data/lib/woods/hooks/context_hint.rb +73 -0
  132. data/lib/woods/hooks/context_impact.rb +77 -0
  133. data/lib/woods/hooks/context_output.rb +47 -0
  134. data/lib/woods/hooks/context_state.rb +102 -0
  135. data/lib/woods/hooks/refresh.rb +79 -0
  136. data/lib/woods/hooks/rule_projection.rb +78 -0
  137. data/lib/woods/input_rules.rb +19 -0
  138. data/lib/woods/mcp/bearer_auth.rb +22 -13
  139. data/lib/woods/mcp/bootstrapper.rb +79 -4
  140. data/lib/woods/mcp/config_resolver.rb +2 -1
  141. data/lib/woods/mcp/index_reader.rb +334 -162
  142. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  143. data/lib/woods/mcp/origin_guard.rb +17 -9
  144. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  145. data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
  146. data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
  147. data/lib/woods/mcp/search_results.rb +74 -0
  148. data/lib/woods/mcp/server.rb +178 -63
  149. data/lib/woods/mcp/tool_contract.rb +3 -1
  150. data/lib/woods/mcp/tool_response_renderer.rb +41 -0
  151. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  152. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  153. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  154. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  155. data/lib/woods/mcp/traversal_response.rb +22 -0
  156. data/lib/woods/notion/exporter.rb +56 -17
  157. data/lib/woods/obsidian/destination_plan.rb +98 -0
  158. data/lib/woods/obsidian/name_mapper.rb +19 -3
  159. data/lib/woods/obsidian/note_builder.rb +19 -10
  160. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  161. data/lib/woods/operator/pipeline_guard.rb +18 -13
  162. data/lib/woods/path_dispatcher.rb +13 -6
  163. data/lib/woods/payload_store.rb +27 -26
  164. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  165. data/lib/woods/published_index.rb +2 -2
  166. data/lib/woods/railtie.rb +3 -3
  167. data/lib/woods/railtie_support.rb +12 -12
  168. data/lib/woods/rake_helpers.rb +382 -0
  169. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  170. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  171. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  172. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  173. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  174. data/lib/woods/resilience/index_validator.rb +112 -23
  175. data/lib/woods/retrieval/context_assembler.rb +50 -15
  176. data/lib/woods/retrieval/lexical_assembler.rb +84 -0
  177. data/lib/woods/retrieval/lexical_index.rb +120 -0
  178. data/lib/woods/retrieval/ranker.rb +4 -2
  179. data/lib/woods/retrieval/scope.rb +108 -0
  180. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  181. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  182. data/lib/woods/retrieval/search_executor.rb +86 -27
  183. data/lib/woods/retrieval/source_evidence.rb +200 -0
  184. data/lib/woods/retriever.rb +98 -22
  185. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  186. data/lib/woods/session_tracer/file_store.rb +6 -1
  187. data/lib/woods/session_tracer/middleware.rb +10 -12
  188. data/lib/woods/session_tracer/redis_store.rb +22 -6
  189. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  190. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  191. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  192. data/lib/woods/source_inputs/consumer_errors.rb +31 -0
  193. data/lib/woods/source_inputs/handoff.rb +102 -0
  194. data/lib/woods/source_inputs/launcher.rb +157 -0
  195. data/lib/woods/source_inputs/manifest.rb +124 -0
  196. data/lib/woods/source_inputs/private_key.rb +55 -0
  197. data/lib/woods/source_inputs/scanner.rb +171 -0
  198. data/lib/woods/source_inputs/scopes.rb +71 -0
  199. data/lib/woods/source_inputs/session.rb +214 -0
  200. data/lib/woods/source_inputs/status.rb +84 -0
  201. data/lib/woods/source_inputs/verifier.rb +107 -0
  202. data/lib/woods/storage/metadata_store.rb +25 -25
  203. data/lib/woods/storage/pgvector.rb +35 -10
  204. data/lib/woods/storage/qdrant.rb +17 -7
  205. data/lib/woods/storage/vector_store.rb +18 -6
  206. data/lib/woods/tasks.rb +3 -2
  207. data/lib/woods/temporal/json_snapshot_store.rb +58 -9
  208. data/lib/woods/unblocked/exporter.rb +59 -70
  209. data/lib/woods/version.rb +1 -1
  210. data/lib/woods/watch/boot_snapshot.rb +52 -0
  211. data/lib/woods/watch/daemon.rb +154 -32
  212. data/lib/woods/watch/listen_watcher.rb +4 -0
  213. data/lib/woods/watch/polling_watcher.rb +5 -1
  214. data/lib/woods/watch/status.rb +20 -15
  215. data/lib/woods/watch/tree_scan.rb +21 -13
  216. data/lib/woods/watch/watcher.rb +4 -1
  217. data/lib/woods.rb +50 -11
  218. data/plugin/.claude-plugin/plugin.json +1 -1
  219. data/plugin/hooks/adapters/normalize.jq +15 -0
  220. data/plugin/hooks/adapters/normalize.rb +63 -0
  221. data/plugin/hooks/hooks.json +20 -0
  222. data/plugin/hooks/woods-context.sh +50 -0
  223. data/plugin/hooks/woods-input-rules.sh +159 -0
  224. data/plugin/hooks/woods-opencode.mjs +65 -0
  225. data/plugin/hooks/woods-post-edit.sh +2 -225
  226. data/plugin/hooks/woods-refresh.sh +260 -0
  227. data/plugin/hooks/woods-session-start.sh +47 -55
  228. data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
  229. data/plugin/skills/woods-diagnose/SKILL.md +319 -1
  230. data/plugin/skills/woods-investigate/SKILL.md +145 -0
  231. data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
  232. data/plugin/skills/woods-setup/SKILL.md +110 -6
  233. metadata +87 -5
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require 'open3'
5
+ require_relative 'plan'
6
+
7
+ module Woods
8
+ module AgentConfiguration
9
+ module PlanDiff
10
+ def self.show(plan, output)
11
+ plan.data.fetch('changes').each do |change|
12
+ document = Document.new(change.fetch('path'))
13
+ unless document.fingerprint == change.fetch('before')
14
+ raise Conflict, "Changed since preview: #{document.path}; create a new plan before viewing its diff"
15
+ end
16
+
17
+ output.write(render(document, Plan.after_content(change)))
18
+ end
19
+ end
20
+
21
+ def self.render(document, content)
22
+ Tempfile.create('woods-config-before') do |before|
23
+ Tempfile.create('woods-config-after') do |after|
24
+ before.write(document.content.to_s)
25
+ after.write(content.to_s)
26
+ before.flush
27
+ after.flush
28
+ diff, status = Open3.capture2('diff', '-u', '--label', document.path, '--label', document.path,
29
+ before.path, after.path)
30
+ raise Conflict, 'Cannot render plan diff' unless [0, 1].include?(status.exitstatus)
31
+
32
+ diff
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module AgentConfiguration
5
+ # Compute exact document replacements while tracking receipt-owned files.
6
+ module PlannedFiles
7
+ private
8
+
9
+ def prepare_config
10
+ document = Document.new(@layout.config_path)
11
+ value = document.json
12
+ servers = value.fetch('mcpServers', {})
13
+ raise Conflict, "mcpServers must be an object: #{document.path}" unless servers.is_a?(Hash)
14
+
15
+ validate_server_ownership!(servers)
16
+ @created_files = @previous ? @previous.fetch('created_files').dup : []
17
+ track_created_file(document)
18
+ if @operation == 'remove'
19
+ servers.delete(@name)
20
+ else
21
+ servers[@name] = @entry
22
+ end
23
+ value['mcpServers'] = servers
24
+ @plan.add(document, config_content(document, value),
25
+ description: "#{@operation} owned Index MCP entry #{@name}")
26
+ end
27
+
28
+ def config_content(document, value)
29
+ return if @operation == 'remove' && @created_files.include?(document.path) && value == { 'mcpServers' => {} }
30
+
31
+ document.encode_json(value)
32
+ end
33
+
34
+ def prepare_sections
35
+ previous_sections = @previous ? @previous.fetch('sections') : {}
36
+ requested = @instructions || previous_sections.keys
37
+ requested = [] if @operation == 'remove'
38
+ validate_instruction_selection!(requested, previous_sections)
39
+ @sections = {}
40
+ (previous_sections.keys | requested).each do |name|
41
+ prepare_section(name, previous_sections[name], removing: !requested.include?(name))
42
+ end
43
+ end
44
+
45
+ def prepare_section(name, previous, removing:)
46
+ document = Document.new(@layout.instruction_path(name))
47
+ content, ownership = ManagedSection.change(document.content, previous: previous, remove: removing)
48
+ track_created_file(document)
49
+ content = nil if removing && content == '' && @created_files.include?(document.path)
50
+ @sections[name] = ownership if ownership
51
+ @plan.add(document, content, description: "#{removing ? 'remove' : 'write'} owned Woods instruction section")
52
+ end
53
+
54
+ def track_created_file(document)
55
+ return unless document.content.nil? && !@created_files.include?(document.path)
56
+
57
+ @created_files << document.path
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'plan'
4
+ require_relative 'managed_section'
5
+ require_relative 'planner_validation'
6
+ require_relative 'planned_files'
7
+
8
+ module Woods
9
+ module AgentConfiguration
10
+ class Planner
11
+ include PlannerValidation
12
+ include PlannedFiles
13
+
14
+ TEMPLATE_VERSION = 1
15
+
16
+ def initialize(layout:, operation:, entry: nil, **options)
17
+ unknown = options.keys - %i[name instructions evidence intent]
18
+ raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" unless unknown.empty?
19
+
20
+ name = options.fetch(:name, 'woods')
21
+ raise Conflict, 'Supported operations: setup, update, remove' unless %w[setup update remove].include?(operation)
22
+ raise Conflict, 'Server name must be a simple nonempty name' unless name.match?(/\A[a-zA-Z0-9_-]+\z/)
23
+
24
+ @layout = layout
25
+ @operation = operation
26
+ @entry = entry
27
+ @name = name
28
+ @instructions = options[:instructions]
29
+ @evidence = options.fetch(:evidence, {})
30
+ @intent = options.fetch(:intent, {})
31
+ end
32
+
33
+ def call
34
+ @receipt_document = Document.new(@layout.receipt_path)
35
+ @previous = @receipt_document.content && @receipt_document.json
36
+ validate_receipt!
37
+ @plan = Plan.new(layout: @layout, operation: @operation, evidence: @evidence)
38
+ return @plan if @operation == 'remove' && @previous.nil?
39
+
40
+ prepare_config
41
+ prepare_sections
42
+ prepare_receipt
43
+ @plan
44
+ end
45
+
46
+ private
47
+
48
+ def prepare_receipt
49
+ if @operation == 'remove'
50
+ @plan.add(@receipt_document, nil, description: 'remove installation receipt')
51
+ return
52
+ end
53
+ receipt = { 'schema_version' => 1, 'template_version' => TEMPLATE_VERSION, 'layout' => @layout.identity,
54
+ 'server_name' => @name, 'entry' => @entry, 'sections' => @sections,
55
+ 'created_files' => @created_files, 'intent' => @intent, 'evidence' => @evidence }
56
+ # A repeated setup never silently upgrades the installation's template
57
+ # or evidence version. The explicit update operation owns that change.
58
+ receipt = @previous if @operation == 'setup' && @previous && @plan.data.fetch('changes').empty?
59
+ @plan.add(@receipt_document, @receipt_document.encode_json(receipt), description: 'record owned installation')
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module AgentConfiguration
5
+ # Receipt and command validation shared by the planner's file preparations.
6
+ module PlannerValidation
7
+ private
8
+
9
+ def validate_receipt!
10
+ if @previous
11
+ validate_receipt_identity!
12
+ elsif @operation == 'update'
13
+ raise Conflict, 'No owned installation to update; run setup first'
14
+ end
15
+ return if @operation == 'remove'
16
+
17
+ validate_entry!
18
+ validate_setup_compatibility! if @operation == 'setup' && @previous
19
+ end
20
+
21
+ def validate_receipt_identity!
22
+ valid = @previous['schema_version'] == 1 && @previous['layout'] == @layout.identity &&
23
+ @previous['server_name'] == @name && @previous['sections'].is_a?(Hash) &&
24
+ @previous['created_files'].is_a?(Array)
25
+ raise Conflict, 'Installation receipt does not match this application/client/scope/server' unless valid
26
+
27
+ validate_receipt_content!
28
+ end
29
+
30
+ def validate_receipt_content!
31
+ sections = @previous.fetch('sections')
32
+ valid_sections = sections.values.all? do |section|
33
+ section.is_a?(Hash) && section['owned_text'].is_a?(String) && section['prefix'].is_a?(String)
34
+ end
35
+ valid_files = (@previous.fetch('created_files') - @layout.allowed_paths).empty?
36
+ return if valid_sections && valid_files && @previous['entry'].is_a?(Hash)
37
+
38
+ raise Conflict, 'Malformed ownership receipt; restore it before updating or removing configuration'
39
+ end
40
+
41
+ def validate_entry!
42
+ return if @entry.is_a?(Hash) && @entry['command'].is_a?(String) && @entry['args'].is_a?(Array)
43
+
44
+ raise Conflict, 'A preflight-validated Index MCP command is required'
45
+ end
46
+
47
+ def validate_setup_compatibility!
48
+ if @previous['entry'] != @entry
49
+ raise Conflict, 'Setup differs from the owned installation; preview an explicit update'
50
+ end
51
+ return if @previous['template_version'] == self.class::TEMPLATE_VERSION
52
+
53
+ raise Conflict, 'The instruction template changed; preview an explicit update'
54
+ end
55
+
56
+ def validate_server_ownership!(servers)
57
+ if @previous
58
+ return if servers[@name] == @previous.fetch('entry')
59
+
60
+ raise Conflict, 'The owned MCP entry was edited or removed; restore it or resolve ownership manually'
61
+ end
62
+ return unless servers.key?(@name)
63
+
64
+ raise Conflict, "MCP entry #{@name.inspect} already exists without a Woods receipt; choose another name"
65
+ end
66
+
67
+ def validate_instruction_selection!(requested, previous_sections)
68
+ unless requested.is_a?(Array) && requested.uniq == requested
69
+ raise Conflict, 'Instruction files must be a unique list'
70
+ end
71
+ return unless @operation == 'setup' && @previous && requested.sort != previous_sections.keys.sort
72
+
73
+ raise Conflict, 'Instruction selection differs; preview an explicit update'
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'timeout'
6
+ require 'bundler'
7
+ require_relative 'error'
8
+
9
+ module Woods
10
+ module AgentConfiguration
11
+ class Preflight
12
+ REQUIRED_TOOLS = %w[woods_status search lookup dependencies dependents].freeze
13
+ SCRIPT = <<~'RUBY'
14
+ require 'timeout'
15
+ Timeout.timeout(25) do
16
+ require 'json'
17
+ require 'woods'
18
+ require 'woods/mcp/server'
19
+ require 'woods/resilience/index_validator'
20
+ root = File.realpath(ARGV.fetch(1))
21
+ index = File.realpath(ARGV.fetch(0))
22
+ spec = Gem.loaded_specs.fetch('woods')
23
+ abort 'Installed Woods is missing woods-mcp-start' unless spec.executables.include?('woods-mcp-start')
24
+ reader = Woods::MCP::IndexReader.new(index)
25
+ manifest = reader.manifest
26
+ abort 'Published extraction manifest missing or malformed' unless manifest.is_a?(Hash) && manifest['counts'].is_a?(Hash)
27
+ report = Woods::Resilience::IndexValidator.new(index_dir: index, app_root: root).validate
28
+ abort report.errors.join("\n") unless report.valid?
29
+ tools = Woods::MCP::Server.build(index_dir: index, warmup: false).tools.keys
30
+ puts JSON.generate(version: Woods::VERSION, tools: tools.sort, root: root, index: index,
31
+ generation: reader.loaded_generation, warnings: report.warnings)
32
+ end
33
+ RUBY
34
+
35
+ def initialize(timeout: 30)
36
+ @timeout = timeout
37
+ end
38
+
39
+ def call(launcher)
40
+ output = run(launcher)
41
+ evidence = JSON.parse(output)
42
+ raise Conflict, 'Installed Woods preflight expected a JSON object' unless evidence.is_a?(Hash)
43
+
44
+ missing = REQUIRED_TOOLS - Array(evidence['tools'])
45
+ raise Conflict, "Installed Woods lacks required Index capabilities: #{missing.join(', ')}" unless missing.empty?
46
+ raise Conflict, 'Installed Woods did not report its version' unless evidence['version'].is_a?(String)
47
+
48
+ evidence
49
+ rescue JSON::ParserError
50
+ raise Conflict, 'Installed Woods preflight did not return valid JSON; inspect application bundle and index'
51
+ end
52
+
53
+ private
54
+
55
+ def run(launcher)
56
+ command = launcher.probe_command(SCRIPT)
57
+ environment = Bundler.unbundled_env.merge(launcher.probe_environment)
58
+ Open3.popen3(environment, *command, chdir: launcher.root, unsetenv_others: true,
59
+ pgroup: true) do |stdin, out, err, wait|
60
+ stdin.close
61
+ collect(out, err, wait)
62
+ end
63
+ rescue Errno::ENOENT => e
64
+ raise Conflict, "Launch command unavailable: #{e.message}"
65
+ end
66
+
67
+ def collect(stdout, stderr, wait)
68
+ readers = [stdout, stderr].map { |stream| Thread.new { bounded_read(stream) } }
69
+ Timeout.timeout(@timeout) do
70
+ output, errors = readers.map(&:value)
71
+ raise Conflict, "Installed Woods preflight failed: #{errors.strip}" unless wait.value.success?
72
+
73
+ output
74
+ end
75
+ rescue Timeout::Error
76
+ raise Conflict, "Installed Woods preflight exceeded #{@timeout}s; verify the selected command and container"
77
+ ensure
78
+ terminate(wait.pid)
79
+ readers&.each(&:kill)
80
+ end
81
+
82
+ def bounded_read(stream)
83
+ output = String.new
84
+ loop do
85
+ chunk = stream.readpartial(16_384)
86
+ output << chunk
87
+ raise Conflict, 'Installed Woods preflight output exceeded 1 MiB' if output.bytesize > 1_048_576
88
+ end
89
+ rescue EOFError
90
+ output
91
+ end
92
+
93
+ def terminate(pid)
94
+ Process.kill('KILL', -pid)
95
+ rescue Errno::ESRCH
96
+ nil
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module AgentConfiguration
5
+ # Validate every journal snapshot before restoring any managed file.
6
+ module Recovery
7
+ private
8
+
9
+ def recover_journal(journal)
10
+ plan = Plan.allocate
11
+ plan.instance_variable_set(:@data, journal.fetch('plan'))
12
+ plan.validate!(@layout)
13
+ changes = plan.data.fetch('changes')
14
+ originals = journal.fetch('originals')
15
+ validate_originals!(originals, changes)
16
+ changes.each { |change| validate_recovery_snapshot!(change) }
17
+ originals.zip(changes).reverse_each { |original, change| restore_original(original, change) }
18
+ File.unlink(journal_path)
19
+ rescue KeyError, TypeError, ArgumentError => e
20
+ raise Conflict, "Invalid recovery journal; retained for manual recovery: #{e.message}"
21
+ end
22
+
23
+ def validate_originals!(originals, changes)
24
+ paths = changes.map { |entry| entry['path'] }
25
+ return if originals.is_a?(Array) && originals.all?(Hash) && originals.map { |entry| entry['path'] } == paths
26
+
27
+ raise Conflict, "Invalid recovery journal; retain it for manual recovery: #{journal_path}"
28
+ end
29
+
30
+ def validate_recovery_snapshot!(change)
31
+ return if [change.fetch('before'), Plan.after_fingerprint(change)].include?(current(change))
32
+
33
+ raise Conflict,
34
+ "Concurrent edit prevents recovery of #{change.fetch('path')}; journal retained: #{journal_path}"
35
+ end
36
+
37
+ def restore_original(original, change)
38
+ return if current(change) == change.fetch('before')
39
+
40
+ content = original['content'] && Base64.strict_decode64(original['content']).force_encoding(Encoding::UTF_8)
41
+ expected = { 'sha256' => content && Digest::SHA256.hexdigest(content),
42
+ 'mode' => original.fetch('mode'), 'exists' => !content.nil? }
43
+ assert_snapshot!(expected, change.fetch('before'), original.fetch('path'))
44
+ assert_snapshot!(current(change), Plan.after_fingerprint(change), original.fetch('path'))
45
+ replace(original.fetch('path'), content, original.fetch('mode'))
46
+ end
47
+ end
48
+ end
49
+ end
@@ -26,6 +26,8 @@ module Woods
26
26
  :arguments, # Array<String>: argument representations (for :send)
27
27
  :source, # String | nil: raw source text of this node
28
28
  :end_line, # Integer | nil: 1-based end line number (when available)
29
+ :start_byte, # Integer | nil: inclusive byte offset into the parsed source
30
+ :end_byte, # Integer | nil: exclusive byte offset into the parsed source
29
31
  keyword_init: true
30
32
  ) do
31
33
  # Find all descendant nodes matching a type.
@@ -32,6 +32,21 @@ module Woods
32
32
  raise Woods::ExtractionError, "Failed to parse source: #{e.message}"
33
33
  end
34
34
 
35
+ # Validate a candidate excerpt without logging expected syntax failures
36
+ # (for example a heredoc body outside a method's syntactic AST range).
37
+ # The parser instance owns its diagnostics; no global stderr redirection.
38
+ def valid_fragment?(source)
39
+ return Prism.parse(source).success? if prism_available?
40
+
41
+ require 'parser/current' unless defined?(::Parser::CurrentRuby)
42
+ parser = ::Parser::CurrentRuby.default_parser
43
+ parser.diagnostics.consumer = nil
44
+ buffer = ::Parser::Source::Buffer.new('(fragment)', source: source)
45
+ !parser.parse(buffer).nil?
46
+ rescue StandardError
47
+ false
48
+ end
49
+
35
50
  # Check if Prism is available.
36
51
  #
37
52
  # @return [Boolean]
@@ -262,7 +277,9 @@ module Woods
262
277
  end_line: end_line_for_prism(prism_node),
263
278
  method_name: prism_node.name.to_s,
264
279
  receiver: receiver_text,
265
- source: extract_prism_source_span(prism_node, source)
280
+ source: extract_prism_source_span(prism_node, source),
281
+ start_byte: prism_node.location.start_offset,
282
+ end_byte: prism_node.location.end_offset
266
283
  )
267
284
  end
268
285
 
@@ -524,12 +541,16 @@ module Woods
524
541
  line: parser_node.loc.line,
525
542
  end_line: parser_node.loc.expression.last_line,
526
543
  method_name: parser_node.children[0].to_s,
527
- source: extract_parser_source_span(parser_node, source)
544
+ source: extract_parser_source_span(parser_node, source),
545
+ start_byte: original_byte_offset(source, parser_node.loc.expression.line,
546
+ parser_node.loc.expression.column),
547
+ end_byte: original_byte_offset(source, parser_node.loc.expression.last_line,
548
+ parser_node.loc.expression.last_column)
528
549
  )
529
550
  when :defs
530
551
  body = parser_node.children[3] ? convert_parser_node(parser_node.children[3], source) : nil
531
552
  body_children = parser_body_children(body)
532
- receiver = parser_node.children[0].type == :self ? 'self' : parser_node.children[0].to_s
553
+ receiver = extract_parser_receiver_text(parser_node.children[0], source)
533
554
  Node.new(
534
555
  type: :defs,
535
556
  children: body_children,
@@ -537,7 +558,11 @@ module Woods
537
558
  end_line: parser_node.loc.expression.last_line,
538
559
  method_name: parser_node.children[1].to_s,
539
560
  receiver: receiver,
540
- source: extract_parser_source_span(parser_node, source)
561
+ source: extract_parser_source_span(parser_node, source),
562
+ start_byte: original_byte_offset(source, parser_node.loc.expression.line,
563
+ parser_node.loc.expression.column),
564
+ end_byte: original_byte_offset(source, parser_node.loc.expression.last_line,
565
+ parser_node.loc.expression.last_column)
541
566
  )
542
567
  when :send
543
568
  receiver_text = parser_node.children[0] ? extract_parser_receiver_text(parser_node.children[0], source) : nil
@@ -611,8 +636,16 @@ module Woods
611
636
  end
612
637
  end
613
638
 
639
+ # parser gem normalizes CRLF in its source buffer. Its line/character
640
+ # columns still identify the original source, but absolute positions do
641
+ # not. Map through original lines to preserve both CRLF and Unicode bytes.
642
+ def original_byte_offset(source, line, column)
643
+ lines = source.lines
644
+ lines.take(line - 1).sum(&:bytesize) + lines.fetch(line - 1)[0, column].bytesize
645
+ end
646
+
614
647
  def parser_body_children(body_node)
615
- body_node&.type == :begin ? body_node.children : [body_node].compact
648
+ body_node.is_a?(Node) && body_node.type == :begin ? body_node.children : [body_node].compact
616
649
  end
617
650
 
618
651
  def extract_parser_source_span(node, source)
data/lib/woods/builder.rb CHANGED
@@ -110,6 +110,11 @@ module Woods
110
110
  # on disk and passes the populated store here.
111
111
  # @return [Retriever, Cache::CachedRetriever] A fully wired retriever
112
112
  def build_retriever(vector_store: nil, metadata_store: nil, graph_store: nil)
113
+ if @config.retrieval_mode == :lexical
114
+ return build_lexical_retriever(metadata_store: metadata_store,
115
+ graph_store: graph_store)
116
+ end
117
+
113
118
  provider = build_resilient_embedding_provider
114
119
  cache = build_cache_store
115
120
 
@@ -119,12 +124,21 @@ module Woods
119
124
  vector_store: vector_store || build_vector_store(dimensions: vector_dimensions(provider)),
120
125
  metadata_store: metadata_store || build_metadata_store,
121
126
  graph_store: graph_store || build_graph_store,
122
- embedding_provider: provider
127
+ embedding_provider: provider, default_budget: @config.max_context_tokens
123
128
  )
124
129
 
125
130
  cache ? wrap_with_retriever_cache(retriever, cache) : retriever
126
131
  end
127
132
 
133
+ def build_lexical_retriever(metadata_store:, graph_store:)
134
+ retriever = Retriever.new(vector_store: nil, metadata_store: metadata_store || build_metadata_store,
135
+ graph_store: graph_store, embedding_provider: nil, mode: :lexical,
136
+ default_budget: @config.max_context_tokens)
137
+ cache = build_cache_store
138
+ cache ? wrap_with_retriever_cache(retriever, cache) : retriever
139
+ end
140
+ private :build_lexical_retriever
141
+
128
142
  # Instantiate the vector store adapter specified by the configuration.
129
143
  #
130
144
  # The :pgvector branch also ensures the adapter's own schema exists —
@@ -482,19 +496,21 @@ module Woods
482
496
 
483
497
  # Instantiate the metadata store adapter specified by the configuration.
484
498
  #
499
+ # @param output_dir [String, Pathname] Default SQLite database directory; an
500
+ # explicit metadata_store_options[:database] still takes precedence.
485
501
  # @return [Storage::MetadataStore::Interface] Metadata store adapter instance
486
502
  # @raise [ArgumentError] if the configured type is not recognized
487
- def build_metadata_store
503
+ def build_metadata_store(output_dir: @config.output_dir)
488
504
  case @config.metadata_store
489
505
  when :in_memory then Storage::MetadataStore::InMemory.new
490
- when :sqlite then Storage::MetadataStore::SQLite.new(**sqlite_metadata_options)
506
+ when :sqlite then Storage::MetadataStore::SQLite.new(**sqlite_metadata_options(output_dir))
491
507
  else raise ArgumentError, "Unknown metadata_store: #{@config.metadata_store}"
492
508
  end
493
509
  end
494
510
 
495
- def sqlite_metadata_options
511
+ def sqlite_metadata_options(output_dir)
496
512
  opts = (@config.metadata_store_options || {}).transform_keys(&:to_sym)
497
- opts[:database] ||= File.join(@config.output_dir.to_s, 'metadata.sqlite3')
513
+ opts[:database] ||= File.join(output_dir.to_s, 'metadata.sqlite3')
498
514
  opts
499
515
  end
500
516
  private :sqlite_metadata_options
@@ -418,6 +418,8 @@ module Woods
418
418
  def vector_store = @retriever.vector_store
419
419
  def metadata_store = @retriever.metadata_store
420
420
  def graph_store = @retriever.graph_store
421
+ def mode = @retriever.respond_to?(:mode) ? @retriever.mode : :semantic
422
+ def default_budget = @retriever.respond_to?(:default_budget) ? @retriever.default_budget : 8000
421
423
 
422
424
  # Invalidate every cached context result. Called from the MCP +reload+
423
425
  # tool after the retriever's stores have been re-hydrated from a fresh
@@ -449,12 +451,17 @@ module Woods
449
451
  # @param types [Array<String, Symbol>, nil] Include-only filter
450
452
  # @param exclude_types [Array<String, Symbol>, nil] Additional exclusions
451
453
  # @return [Retriever::RetrievalResult]
452
- def retrieve(query, budget: 8000, types: nil, exclude_types: nil)
453
- key = context_key(query, budget, types: types, exclude_types: exclude_types)
454
+ def retrieve(query, budget: default_budget, types: nil, exclude_types: nil, packages: nil, source_paths: nil,
455
+ evidence: 'full') # rubocop:disable Metrics/ParameterLists
456
+ Retrieval::SourceEvidence.validate_mode!(evidence)
457
+ scoped = Retrieval::Scope.requested?(packages: packages, source_paths: source_paths)
458
+ scope_options = scoped ? { packages: packages, source_paths: source_paths } : {}
459
+ scope_options[:evidence] = evidence unless evidence == 'full'
460
+ key = context_key(query, budget, types: types, exclude_types: exclude_types, **scope_options)
454
461
  cached = @cache_store.read(key)
455
462
  return rehydrate_cached(cached, budget) if cached
456
463
 
457
- result = @retriever.retrieve(query, budget: budget, types: types, exclude_types: exclude_types)
464
+ result = @retriever.retrieve(query, budget: budget, types: types, exclude_types: exclude_types, **scope_options)
458
465
 
459
466
  begin
460
467
  @cache_store.write(key, serialize_result(result), ttl: @context_ttl)
@@ -478,8 +485,14 @@ module Woods
478
485
  # @param types [Array<String, Symbol>, nil]
479
486
  # @param exclude_types [Array<String, Symbol>, nil]
480
487
  # @return [String]
481
- def context_key(query, budget, types: nil, exclude_types: nil)
482
- Cache.cache_key(:context, query, budget.to_s, fingerprint(types), fingerprint(exclude_types))
488
+ def context_key(query, budget, types: nil, exclude_types: nil, packages: nil, source_paths: nil, evidence: 'full') # rubocop:disable Metrics/ParameterLists
489
+ parts = [query, budget.to_s, fingerprint(types), fingerprint(exclude_types)]
490
+ parts << 'lexical' if mode == :lexical
491
+ parts << JSON.generate(evidence: evidence) unless evidence == 'full'
492
+ if Retrieval::Scope.requested?(packages: packages, source_paths: source_paths)
493
+ parts << JSON.generate(packages: packages, source_paths: source_paths)
494
+ end
495
+ Cache.cache_key(:context, *parts)
483
496
  end
484
497
 
485
498
  def fingerprint(types)
@@ -504,7 +517,8 @@ module Woods
504
517
  tokens_used: cached['tokens_used'],
505
518
  budget: budget,
506
519
  trace: nil,
507
- type_rank_context: rehydrate_type_rank_context(cached['type_rank_context'])
520
+ type_rank_context: rehydrate_type_rank_context(cached['type_rank_context']),
521
+ applied_scope: rehydrate_scope(cached['applied_scope'])
508
522
  )
509
523
  end
510
524
 
@@ -514,10 +528,17 @@ module Woods
514
528
  'sources' => result.sources,
515
529
  'strategy' => result.strategy&.to_s,
516
530
  'tokens_used' => result.tokens_used,
517
- 'type_rank_context' => serialize_type_rank_context(result.type_rank_context)
531
+ 'type_rank_context' => serialize_type_rank_context(result.type_rank_context),
532
+ 'applied_scope' => result.applied_scope
518
533
  }
519
534
  end
520
535
 
536
+ def rehydrate_scope(scope)
537
+ return nil unless scope
538
+
539
+ scope.transform_keys(&:to_sym).tap { |value| value[:outcome] = value[:outcome]&.to_sym }
540
+ end
541
+
521
542
  # type_rank_context is a Hash<String => Hash<Symbol, ...>> with
522
543
  # :source carrying a Symbol value. JSON-backed caches (Redis,
523
544
  # SolidCache) collapse both to strings on the round-trip, so we
@@ -25,11 +25,10 @@ module Woods
25
25
  # @param parts [Array<String>] Key components (will be SHA256-hashed if long)
26
26
  # @return [String] Namespaced key
27
27
  def self.cache_key(domain, *parts)
28
- raw = if parts.one?
29
- parts.first.to_s
30
- else
31
- parts.map { |part| "#{part.to_s.bytesize}:#{part}" }.join
32
- end
28
+ raw = parts.map do |part|
29
+ value = part.to_s
30
+ "#{value.bytesize}:#{value}"
31
+ end.join
33
32
  suffix = raw.length > 64 ? Digest::SHA256.hexdigest(raw) : raw
34
33
  "woods:cache:#{domain}:#{suffix}"
35
34
  end
@@ -15,7 +15,8 @@ module Woods
15
15
  #
16
16
  # Paths are held absolute (matching how {DependencyGraph} registers file
17
17
  # paths) and exposed relative on demand (matching how {PathDispatcher}
18
- # rules are written).
18
+ # rules are written). Normalization is lexical, so vanished files remain
19
+ # representable without resolving symlink targets or consulting the filesystem.
19
20
  #
20
21
  # @example
21
22
  # cs = Woods::ChangeSet.new(paths: %w[app/models/user.rb app/models/gone.rb], root: Rails.root)
@@ -29,7 +30,7 @@ module Woods
29
30
  # @param paths [Array<String>] changed paths, absolute or root-relative
30
31
  # @param root [String, Pathname] application root (usually Rails.root)
31
32
  def initialize(paths:, root:)
32
- @root = Pathname.new(root.to_s)
33
+ @root = Pathname.new(root.to_s).expand_path
33
34
  @absolute_paths = Array(paths).filter_map { |p| absolutize(p) }.uniq.freeze
34
35
  end
35
36
 
@@ -72,7 +73,7 @@ module Woods
72
73
  # @param path [String] absolute path
73
74
  # @return [String] root-relative path, or the input if outside {#root}
74
75
  def relativize(path)
75
- prefix = "#{@root}/"
76
+ prefix = File.join(@root.to_s, '')
76
77
  path.start_with?(prefix) ? path.delete_prefix(prefix) : path
77
78
  end
78
79
 
@@ -82,7 +83,7 @@ module Woods
82
83
  str = path.to_s.strip
83
84
  return nil if str.empty?
84
85
 
85
- Pathname.new(str).absolute? ? str : @root.join(str).to_s
86
+ @root.join(str).cleanpath.to_s
86
87
  end
87
88
  end
88
89
  end