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,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module MCP
5
+ # Short, client-neutral instructions for initialize and modern discovery.
6
+ # Only tool registration shapes the text: readiness and retrieval can
7
+ # change after startup, so clients must consult live status before use.
8
+ module InitializationGuidance
9
+ # Contract budget for all supported wiring combinations, tested at build.
10
+ MAX_BYTES = 2048
11
+
12
+ WORKFLOW = <<~TEXT
13
+ Start with woods_status: check index readiness, generation freshness and relevant type counts before relying on results.
14
+ For exact names, discover identifiers with search (prefer literal exact_prefix/exact_suffix), then inspect with lookup. For conceptual questions, use codebase_retrieve only when woods_status reports retrieval enabled. Otherwise use search and lookup.
15
+ Follow dependencies or dependents at depth 1 or 2; narrow types and via before paging. Respect partial results and limits: a missing match is not proof of absence, and a partial traversal does not establish every dependent or leaf.
16
+ Verify important conclusions against current source and tests. Recorded relationships and inferred downstream impact do not prove runtime execution or test coverage.
17
+ Registration does not authorize extraction, configuration changes, or live Console access. Use only the tools registered here and operate within the user's authorized scope.
18
+ TEXT
19
+
20
+ # @param tool_names [Array<String>] actual names after conditional registration
21
+ # @return [String] stable instructions, without index data or provider calls
22
+ def self.for(tool_names)
23
+ "#{WORKFLOW}Registered tools: #{tool_names.sort.join(', ')}"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -62,15 +62,12 @@ module Woods
62
62
  # @param enabled [#call, nil] Optional request-time predicate. When it
63
63
  # returns falsy the request passes through unguarded. Nil (the
64
64
  # default) means always guard.
65
- def initialize(app, allowed_origins: nil, path: nil, enabled: nil)
66
- @app = app
67
- @path = path
68
- @enabled = enabled
69
- if allowed_origins.respond_to?(:call)
70
- @allowed_source = allowed_origins
71
- else
72
- build_allow_list(allowed_origins)
73
- end
65
+ # Rails 6.0 forwards middleware options as a positional hash on Ruby 3.
66
+ # Delegate to explicit keywords to preserve required/unknown option checks.
67
+ def initialize(app, options = {}, **keywords)
68
+ raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
69
+
70
+ initialize_options(app, **options, **keywords)
74
71
  end
75
72
 
76
73
  # Rack entry point. Out-of-scope requests (non-matching `path:` prefix
@@ -97,6 +94,17 @@ module Woods
97
94
 
98
95
  private
99
96
 
97
+ def initialize_options(app, allowed_origins: nil, path: nil, enabled: nil)
98
+ @app = app
99
+ @path = path
100
+ @enabled = enabled
101
+ if allowed_origins.respond_to?(:call)
102
+ @allowed_source = allowed_origins
103
+ else
104
+ build_allow_list(allowed_origins)
105
+ end
106
+ end
107
+
100
108
  # @param env [Hash] Rack environment
101
109
  # @return [Boolean] whether this request falls under the guard
102
110
  def guard?(env)
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'index_reader'
4
+ require_relative '../retriever'
5
+ require_relative '../storage/metadata_store'
6
+ require_relative '../storage_identity'
7
+
8
+ module Woods
9
+ module MCP
10
+ # Loads only authoritative published extraction units. The reader pin spans
11
+ # snapshot construction AND the query, so a publication cannot mix units.
12
+ # No provider, vector adapter, host source read, or context cache is involved.
13
+ class PublishedLexicalRetriever
14
+ attr_reader :reader, :snapshot, :default_budget
15
+
16
+ def initialize(index_dir:, reader: nil, default_budget: 8000)
17
+ @default_budget = default_budget
18
+ @index_dir = Pathname.new(index_dir)
19
+ @reader = reader || IndexReader.new(index_dir)
20
+ @snapshot_mutex = Mutex.new
21
+ @snapshot = nil
22
+ end
23
+
24
+ def mode = :lexical
25
+ def vector_store = nil
26
+ def graph_store = nil
27
+ def metadata_store = @snapshot&.last&.metadata_store
28
+
29
+ # Server uses one reader for status, tool pinning and lexical retrieval.
30
+ # Called only during server construction, before request threads start.
31
+ def bind_reader(reader)
32
+ @reader = reader
33
+ @snapshot = nil
34
+ self
35
+ end
36
+
37
+ def warmup!
38
+ validate_publication!
39
+ reader.with_pinned_generation { snapshot_for_reader }
40
+ self
41
+ end
42
+
43
+ def retrieve(query, budget: @default_budget, **options)
44
+ validate_publication!
45
+ reader.with_pinned_generation do
46
+ retriever = snapshot_for_reader
47
+ options[:evidence_generation] = reader.loaded_generation if options[:evidence] && options[:evidence] != 'full'
48
+ result = retriever.retrieve(query, budget: budget, **options)
49
+ result.sources.each { |source| source[:generation] = reader.loaded_generation }
50
+ result
51
+ end
52
+ rescue Retriever::StoreError
53
+ raise
54
+ rescue StandardError => e
55
+ raise if e.is_a?(Woods::InvalidQueryError) || e.is_a?(ArgumentError)
56
+
57
+ raise Retriever::StoreError, "lexical index read failed: #{e.class}: #{e.message}"
58
+ end
59
+
60
+ # Explicit invalidation is used by reload after its candidate has been
61
+ # validated. Ordinary generation changes rebuild lazily under the pin.
62
+ def invalidate_snapshot!
63
+ @snapshot_mutex.synchronize { @snapshot = nil }
64
+ end
65
+
66
+ def install_snapshot!(snapshot)
67
+ @snapshot_mutex.synchronize { @snapshot = snapshot }
68
+ end
69
+
70
+ private
71
+
72
+ def validate_publication!
73
+ path = @index_dir.join(Generation::FILENAME)
74
+ unless path.exist?
75
+ raise IOError, 'published generation marker disappeared' if reader.loaded_generation
76
+
77
+ return
78
+ end
79
+ data = JSON.parse(AtomicFile.read(path))
80
+ unless data.is_a?(Hash) && data['number'].is_a?(Integer) && data['number'].positive?
81
+ raise IOError, 'invalid published generation marker'
82
+ end
83
+ return if data['payload'].nil?
84
+
85
+ payload = data['payload']
86
+ unless payload.is_a?(String) && !payload.empty? && !Pathname.new(payload).absolute?
87
+ raise IOError, 'invalid generation payload pointer'
88
+ end
89
+
90
+ directory = @index_dir.join(payload)
91
+ return if directory.directory? && directory.realpath.to_s.start_with?("#{@index_dir.realpath}/")
92
+
93
+ raise IOError, 'missing or escaping generation payload'
94
+ end
95
+
96
+ def snapshot_for_reader
97
+ @snapshot_mutex.synchronize do
98
+ key = reader.generation_identity
99
+ # Flat legacy indexes have no immutable generation identity. Rebuild
100
+ # each time instead of serving changed files from a permanent cache.
101
+ return @snapshot.last if key.first && @snapshot&.first == key
102
+
103
+ metadata = Storage::MetadataStore::InMemory.new
104
+ reader.each_unit do |unit|
105
+ metadata.store(StorageIdentity.key(unit.fetch('identifier'), unit.fetch('type')), unit)
106
+ end
107
+ candidate = Retriever.new(vector_store: nil, metadata_store: metadata, graph_store: nil,
108
+ embedding_provider: nil, mode: :lexical)
109
+ @snapshot = [key.freeze, candidate].freeze
110
+ candidate
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -60,7 +60,7 @@ module Woods
60
60
  lines = []
61
61
  lines << "## Search: \"#{query}\""
62
62
  lines << ''
63
- lines << "#{count} result#{'s' unless count == 1} found."
63
+ lines << "#{count} result#{'s' unless count == 1} returned."
64
64
  lines << ''
65
65
 
66
66
  results.each do |r|
@@ -80,6 +80,8 @@ module Woods
80
80
  lines << "**note:** #{note}" if note
81
81
  end
82
82
 
83
+ lines.concat(search_completeness_lines(data))
84
+
83
85
  lines.join("\n").rstrip
84
86
  end
85
87
 
@@ -431,6 +433,11 @@ module Woods
431
433
  deps.each { |d| lines << "#{indent} - #{d}" }
432
434
  end
433
435
 
436
+ lines.concat(TraversalEvidenceText.lines(fetch_key(data, :explanation)))
437
+
438
+ if fetch_key(data, :partial)
439
+ lines << "Partial traversal (#{fetch_key(data, :partial_reason)}); narrow depth/types/via or increase max_nodes/max_edges."
440
+ end
434
441
  lines << '' << truncation_note(data, nodes.size) if fetch_key(data, :nodes_total)
435
442
 
436
443
  lines.join("\n").rstrip
@@ -63,7 +63,7 @@ module Woods
63
63
  results = fetch_key(data, :results, [])
64
64
 
65
65
  lines = []
66
- lines << "Search: \"#{query}\" (#{count} results)"
66
+ lines << "Search: \"#{query}\" (#{count} result#{'s' unless count == 1} returned)"
67
67
  lines << DIVIDER
68
68
 
69
69
  results.each do |r|
@@ -79,6 +79,7 @@ module Woods
79
79
  partial = fetch_key(data, :partial, false)
80
80
  lines << 'partial: true' if partial
81
81
  lines << "note: #{note}" if note
82
+ lines.concat(search_completeness_lines(data))
82
83
 
83
84
  lines.join("\n").rstrip
84
85
  end
@@ -288,6 +289,11 @@ module Woods
288
289
  deps.each { |d| lines << "#{indent} -> #{d}" }
289
290
  end
290
291
 
292
+ lines.concat(TraversalEvidenceText.lines(fetch_key(data, :explanation)))
293
+
294
+ if fetch_key(data, :partial)
295
+ lines << "Partial traversal (#{fetch_key(data, :partial_reason)}); narrow depth/types/via or increase max_nodes/max_edges."
296
+ end
291
297
  if fetch_key(data, :nodes_total)
292
298
  offset = fetch_key(data, :nodes_offset, 0)
293
299
  position = offset.positive? ? " from offset #{offset}" : ''
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Woods
6
+ module MCP
7
+ # Accumulates a bounded search prefix and evidence about its completeness.
8
+ # A full page is not evidence of truncation: only an additional distinct
9
+ # typed match establishes that more results exist. Budget/timeout cutoffs
10
+ # leave both the total and the existence of another match unknown.
11
+ class SearchResults
12
+ NARROWING_HINT = 'Narrow types, literal exact_prefix/exact_suffix filters, or the requested deep fields.'
13
+
14
+ def self.unavailable
15
+ { status: 'unknown', reason: 'unreadable_or_corrupt_source', has_more: nil,
16
+ total_matches: nil, matched_lower_bound: nil }
17
+ end
18
+
19
+ def initialize(limit:)
20
+ @limit = limit
21
+ @matches = []
22
+ @seen = Set.new
23
+ @reason = nil
24
+ end
25
+
26
+ # Admit at most one lookahead match beyond the returned page.
27
+ # @return [self]
28
+ def add(identifier:, type:, match_field:)
29
+ return self if result_limit_reached?
30
+ return self unless @seen.add?([type, identifier])
31
+
32
+ @matches << { identifier: identifier, type: type, match_field: match_field }
33
+ @reason = 'result_limit' if @matches.size > @limit
34
+ self
35
+ end
36
+
37
+ def result_limit_reached?
38
+ @reason == 'result_limit'
39
+ end
40
+
41
+ # Stop because the remaining search domain could not be examined.
42
+ def stop(reason)
43
+ @reason = reason
44
+ end
45
+
46
+ # The caller must establish exhaustion rather than infer it from page size.
47
+ def finish
48
+ @reason ||= 'exhausted'
49
+ self
50
+ end
51
+
52
+ # @param note [String, nil] existing advisory/timeout guidance
53
+ # @return [Hash] returned rows plus additive completeness metadata
54
+ def response(note: nil)
55
+ raise ArgumentError, 'Search has not finished' unless @reason
56
+
57
+ complete = @reason == 'exhausted'
58
+ result = {
59
+ results: @matches.first(@limit),
60
+ completeness: {
61
+ status: complete ? 'complete' : 'partial', reason: @reason,
62
+ has_more: complete ? false : (true if result_limit_reached?),
63
+ total_matches: complete ? @matches.size : nil,
64
+ matched_lower_bound: @matches.size
65
+ }
66
+ }
67
+ result[:partial] = true unless complete
68
+ result[:hint] = NARROWING_HINT unless complete
69
+ result[:note] = note unless note.nil? || note.empty?
70
+ result
71
+ end
72
+ end
73
+ end
74
+ end