woods 2.0.0.beta1 → 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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +400 -1
  3. data/CONTRIBUTING.md +224 -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 +233 -13
  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 +158 -2
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +15 -7
  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 +71 -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/atomic_file.rb +133 -3
  56. data/lib/woods/builder.rb +21 -5
  57. data/lib/woods/cache/cache_middleware.rb +28 -7
  58. data/lib/woods/cache/cache_store.rb +4 -5
  59. data/lib/woods/change_set.rb +5 -4
  60. data/lib/woods/console/credential_index.rb +20 -2
  61. data/lib/woods/console/credential_scanner.rb +14 -14
  62. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  63. data/lib/woods/console/embedded_executor.rb +1 -1
  64. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  65. data/lib/woods/console/rack_middleware.rb +22 -13
  66. data/lib/woods/console/server.rb +18 -16
  67. data/lib/woods/dependency_graph.rb +65 -13
  68. data/lib/woods/embedding/corpus.rb +94 -0
  69. data/lib/woods/embedding/indexer.rb +90 -46
  70. data/lib/woods/embedding/openai.rb +17 -6
  71. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  72. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  73. data/lib/woods/export/typed_reader.rb +56 -0
  74. data/lib/woods/extractor.rb +557 -228
  75. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  76. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  77. data/lib/woods/extractors/caching_extractor.rb +3 -1
  78. data/lib/woods/extractors/concern_extractor.rb +64 -6
  79. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  80. data/lib/woods/extractors/controller_extractor.rb +13 -4
  81. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  82. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  83. data/lib/woods/extractors/engine_extractor.rb +3 -1
  84. data/lib/woods/extractors/event_extractor.rb +4 -2
  85. data/lib/woods/extractors/factory_extractor.rb +3 -1
  86. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  87. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  88. data/lib/woods/extractors/job_extractor.rb +6 -19
  89. data/lib/woods/extractors/lib_extractor.rb +3 -1
  90. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  91. data/lib/woods/extractors/manager_extractor.rb +3 -1
  92. data/lib/woods/extractors/method_parameters.rb +53 -0
  93. data/lib/woods/extractors/middleware_argument.rb +65 -0
  94. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  95. data/lib/woods/extractors/migration_extractor.rb +3 -1
  96. data/lib/woods/extractors/model_extractor.rb +39 -33
  97. data/lib/woods/extractors/package_extractor.rb +24 -4
  98. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  99. data/lib/woods/extractors/policy_extractor.rb +3 -1
  100. data/lib/woods/extractors/poro_extractor.rb +3 -1
  101. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  102. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  103. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  104. data/lib/woods/extractors/route_extractor.rb +3 -1
  105. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  106. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  107. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  108. data/lib/woods/extractors/service_extractor.rb +3 -1
  109. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  110. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  111. data/lib/woods/extractors/source_nesting.rb +1 -1
  112. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  113. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  114. data/lib/woods/extractors/validator_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  116. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  117. data/lib/woods/flow_assembler.rb +87 -8
  118. data/lib/woods/flow_precomputer.rb +44 -7
  119. data/lib/woods/gem_mapper.rb +2 -0
  120. data/lib/woods/git_history.rb +116 -0
  121. data/lib/woods/graph_analyzer.rb +195 -63
  122. data/lib/woods/hooks/context_cli.rb +54 -0
  123. data/lib/woods/hooks/context_event.rb +88 -0
  124. data/lib/woods/hooks/context_hint.rb +73 -0
  125. data/lib/woods/hooks/context_impact.rb +77 -0
  126. data/lib/woods/hooks/context_output.rb +47 -0
  127. data/lib/woods/hooks/context_state.rb +102 -0
  128. data/lib/woods/hooks/refresh.rb +79 -0
  129. data/lib/woods/hooks/rule_projection.rb +78 -0
  130. data/lib/woods/input_rules.rb +19 -0
  131. data/lib/woods/mcp/bearer_auth.rb +20 -12
  132. data/lib/woods/mcp/bootstrapper.rb +62 -0
  133. data/lib/woods/mcp/index_reader.rb +323 -160
  134. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  135. data/lib/woods/mcp/origin_guard.rb +17 -9
  136. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  137. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  138. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  139. data/lib/woods/mcp/search_results.rb +74 -0
  140. data/lib/woods/mcp/server.rb +158 -37
  141. data/lib/woods/mcp/tool_contract.rb +2 -0
  142. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  143. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  144. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  145. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  146. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  147. data/lib/woods/notion/exporter.rb +56 -17
  148. data/lib/woods/obsidian/destination_plan.rb +98 -0
  149. data/lib/woods/obsidian/name_mapper.rb +19 -3
  150. data/lib/woods/obsidian/note_builder.rb +19 -10
  151. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  152. data/lib/woods/operator/pipeline_guard.rb +18 -13
  153. data/lib/woods/path_dispatcher.rb +7 -1
  154. data/lib/woods/payload_store.rb +29 -15
  155. data/lib/woods/railtie.rb +3 -3
  156. data/lib/woods/railtie_support.rb +12 -12
  157. data/lib/woods/rake_helpers.rb +392 -0
  158. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  159. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  160. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  161. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  162. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  163. data/lib/woods/resilience/index_validator.rb +112 -23
  164. data/lib/woods/retrieval/context_assembler.rb +50 -15
  165. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  166. data/lib/woods/retrieval/lexical_index.rb +119 -0
  167. data/lib/woods/retrieval/ranker.rb +4 -2
  168. data/lib/woods/retrieval/scope.rb +108 -0
  169. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  170. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  171. data/lib/woods/retrieval/search_executor.rb +86 -27
  172. data/lib/woods/retrieval/source_evidence.rb +200 -0
  173. data/lib/woods/retriever.rb +98 -22
  174. data/lib/woods/ruby_analyzer/trace_enricher.rb +80 -38
  175. data/lib/woods/session_tracer/middleware.rb +10 -12
  176. data/lib/woods/session_tracer/redis_store.rb +22 -6
  177. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  178. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  179. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  180. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  181. data/lib/woods/source_inputs/handoff.rb +102 -0
  182. data/lib/woods/source_inputs/launcher.rb +157 -0
  183. data/lib/woods/source_inputs/manifest.rb +124 -0
  184. data/lib/woods/source_inputs/private_key.rb +55 -0
  185. data/lib/woods/source_inputs/scanner.rb +171 -0
  186. data/lib/woods/source_inputs/scopes.rb +71 -0
  187. data/lib/woods/source_inputs/session.rb +214 -0
  188. data/lib/woods/source_inputs/status.rb +84 -0
  189. data/lib/woods/source_inputs/verifier.rb +107 -0
  190. data/lib/woods/storage/metadata_store.rb +25 -25
  191. data/lib/woods/storage/pgvector.rb +29 -8
  192. data/lib/woods/storage/qdrant.rb +17 -7
  193. data/lib/woods/storage/vector_store.rb +18 -6
  194. data/lib/woods/tasks.rb +3 -2
  195. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  196. data/lib/woods/unblocked/exporter.rb +59 -70
  197. data/lib/woods/version.rb +1 -1
  198. data/lib/woods/watch/boot_snapshot.rb +52 -0
  199. data/lib/woods/watch/daemon.rb +136 -28
  200. data/lib/woods/watch/listen_watcher.rb +4 -0
  201. data/lib/woods/watch/polling_watcher.rb +5 -1
  202. data/lib/woods/watch/status.rb +20 -15
  203. data/lib/woods/watch/tree_scan.rb +21 -13
  204. data/lib/woods/watch/watcher.rb +4 -1
  205. data/lib/woods.rb +135 -11
  206. data/plugin/.claude-plugin/plugin.json +1 -1
  207. data/plugin/hooks/adapters/normalize.jq +15 -0
  208. data/plugin/hooks/adapters/normalize.rb +63 -0
  209. data/plugin/hooks/hooks.json +20 -0
  210. data/plugin/hooks/woods-context.sh +50 -0
  211. data/plugin/hooks/woods-input-rules.sh +159 -0
  212. data/plugin/hooks/woods-opencode.mjs +65 -0
  213. data/plugin/hooks/woods-post-edit.sh +2 -225
  214. data/plugin/hooks/woods-refresh.sh +260 -0
  215. data/plugin/hooks/woods-session-start.sh +47 -55
  216. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  217. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  218. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  219. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  220. data/plugin/skills/woods-setup/SKILL.md +107 -6
  221. metadata +84 -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)
@@ -25,11 +25,30 @@ module Woods
25
25
  # one: the watch daemon's +watch_status.json+, read by host-side hooks
26
26
  # through a bind mount).
27
27
  #
28
+ # +durable:+ chooses when the bytes are forced to the disk, never whether
29
+ # the write is atomic. Both paths go tempfile, chmod, rename, so a reader
30
+ # sees the old content or the new content and never a torn partial.
31
+ # +durable: false+ drops the two forced flushes (the temp file's own
32
+ # +fsync+ and the containing directory's), which is the whole cost of the
33
+ # write on a journalling filesystem: roughly 8.9ms per file against
34
+ # 0.11ms without, measured on btrfs.
35
+ #
36
+ # It is only safe for a file with no reader until something else commits
37
+ # it. Woods' payload files qualify: every reader resolves through
38
+ # +generation.json+, and that pointer is written durably after
39
+ # {.sync_directory_tree} has flushed the payload it names. A file whose
40
+ # readers do not go through the pointer (the watch daemon's status, the
41
+ # update check's cache, an export, an embedding checkpoint) must stay
42
+ # durable.
43
+ #
28
44
  # @param path [String, Pathname] destination path
29
45
  # @param content [String] file content
30
46
  # @param mode [Integer] permissions for the written file (default 0600)
47
+ # @param durable [Boolean] force the bytes to disk before returning
48
+ # (default true). Pass false only for a file that something else makes
49
+ # durable before any reader can resolve it.
31
50
  # @return [void]
32
- def write(path, content, mode: 0o600)
51
+ def write(path, content, mode: 0o600, durable: true)
33
52
  path = path.to_s
34
53
  FileUtils.mkdir_p(File.dirname(path))
35
54
  tmp = Tempfile.new('.woods-', File.dirname(path))
@@ -38,19 +57,130 @@ module Woods
38
57
  tmp.binmode
39
58
  tmp.write(content)
40
59
  tmp.flush
41
- tmp.fsync
60
+ tmp.fsync if durable
42
61
  tmp.close
43
62
  # Chmod the temp file so the destination is born with its final
44
63
  # permissions — never observed more open or more closed in between.
45
64
  File.chmod(mode, tmp.path)
46
65
  File.rename(tmp.path, path)
47
- fsync_directory(File.dirname(path))
66
+ fsync_directory(File.dirname(path)) if durable
48
67
  rescue StandardError
49
68
  tmp&.close
50
69
  tmp&.unlink
51
70
  raise
52
71
  end
53
72
 
73
+ # Make every file under +directory+ durable with one filesystem flush.
74
+ #
75
+ # The counterpart to {.write}'s +durable: false+. Thousands of per-file
76
+ # +fsync+ calls and one +syncfs+ buy the same guarantee for a payload that
77
+ # is published all at once, and cost 71.3s against 1.0s for 8000 files on
78
+ # btrfs.
79
+ #
80
+ # Strategies, in order, first one that works wins:
81
+ #
82
+ # 1. +syncfs(2)+ on a descriptor for +directory+, through Fiddle. Linux
83
+ # only, and flushes exactly the one filesystem the payload is on.
84
+ # 2. +sync -f <dir>+ (GNU coreutils), the same call through a subprocess.
85
+ # 3. +sync+ with no arguments (BSD/macOS), which flushes everything
86
+ # mounted rather than one filesystem, but is still one call.
87
+ # 4. an +fsync+ on every file and directory in the tree.
88
+ #
89
+ # The last resort is what keeps the guarantee honest: the chain never
90
+ # silently does nothing, it only ever gets slower.
91
+ #
92
+ # Fiddle is a default gem through Ruby 3.4 and a bundled gem from 3.5, so
93
+ # the require lives inside a rescue and Fiddle is deliberately not in the
94
+ # gemspec. A host without it lands on +sync -f+.
95
+ #
96
+ # @param directory [String, Pathname] the tree to flush
97
+ # @return [Symbol, nil] the strategy that ran (+:syncfs+, +:sync_f+,
98
+ # +:sync+, +:fsync_pass+), or nil when the directory does not exist
99
+ def sync_directory_tree(directory)
100
+ directory = directory.to_s
101
+ return nil unless File.directory?(directory)
102
+
103
+ strategy = syncfs(directory) || sync_f(directory) || plain_sync || fsync_pass(directory)
104
+ log_sync_strategy(strategy, directory)
105
+ strategy
106
+ end
107
+
108
+ # @return [Symbol, nil] +:syncfs+ when the libc call succeeded
109
+ def syncfs(directory)
110
+ call = syncfs_function
111
+ return nil unless call
112
+
113
+ succeeded = File.open(directory, File::RDONLY) { |dir| call.call(dir.fileno).zero? }
114
+ succeeded ? :syncfs : nil
115
+ rescue StandardError
116
+ nil
117
+ end
118
+
119
+ # Resolved once per process, and nil for the whole process when it cannot
120
+ # be. `dlopen` is not free, and a failed `require 'fiddle'` prints a
121
+ # bundled-gem warning on every attempt from Ruby 3.5 onward, and a publish
122
+ # must not emit one line of noise per generation.
123
+ #
124
+ # @return [Fiddle::Function, nil]
125
+ def syncfs_function
126
+ return @syncfs_function if defined?(@syncfs_function)
127
+
128
+ @syncfs_function = begin
129
+ require 'fiddle'
130
+ Fiddle::Function.new(Fiddle.dlopen(nil)['syncfs'], [Fiddle::TYPE_INT], Fiddle::TYPE_INT)
131
+ rescue LoadError, StandardError
132
+ # No Fiddle in the bundle, no libc symbol (macOS has none), or the
133
+ # handle would not open. `sync -f` is next in the chain.
134
+ nil
135
+ end
136
+ end
137
+
138
+ # @return [Symbol, nil] +:sync_f+ when `sync -f` exited 0
139
+ def sync_f(directory)
140
+ :sync_f if system('sync', '-f', directory, out: File::NULL, err: File::NULL)
141
+ rescue StandardError
142
+ nil
143
+ end
144
+
145
+ # @return [Symbol, nil] +:sync+ when a bare `sync` exited 0
146
+ def plain_sync
147
+ :sync if system('sync', out: File::NULL, err: File::NULL)
148
+ rescue StandardError
149
+ nil
150
+ end
151
+
152
+ # @return [Symbol] always +:fsync_pass+; this is the floor of the chain
153
+ def fsync_pass(directory)
154
+ Dir.glob(File.join(directory, '**', '*'), File::FNM_DOTMATCH).each do |entry|
155
+ next if %w[. ..].include?(File.basename(entry))
156
+
157
+ fsync_path(entry)
158
+ end
159
+ fsync_path(directory)
160
+ # The directory entry that names this tree lives in the parent, so a
161
+ # freshly created (or renamed) payload directory is only durable once
162
+ # the parent is flushed too. The three faster strategies flush a whole
163
+ # filesystem and cover this for free; only the scoped pass has to say
164
+ # it.
165
+ fsync_path(File.dirname(directory))
166
+ :fsync_pass
167
+ end
168
+
169
+ # @param path [String] file or directory to flush
170
+ # @return [void]
171
+ def fsync_path(path)
172
+ File.open(path, File::RDONLY, &:fsync)
173
+ rescue Errno::EINVAL, Errno::ENOTSUP, Errno::EISDIR, Errno::ENOENT, Errno::EACCES
174
+ nil
175
+ end
176
+
177
+ # @return [void]
178
+ def log_sync_strategy(strategy, directory)
179
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
180
+
181
+ Rails.logger.debug { "[Woods] payload sync via #{strategy} on #{directory}" }
182
+ end
183
+
54
184
  def fsync_directory(directory)
55
185
  File.open(directory, File::RDONLY, &:fsync)
56
186
  rescue Errno::EINVAL, Errno::ENOTSUP, Errno::EISDIR