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
@@ -18,11 +18,14 @@ module Woods
18
18
  # is what keeps that bounded: skipping `.git`, `node_modules`, `tmp` and
19
19
  # friends takes a Rails app from "everything" to "the source tree".
20
20
  class PollingWatcher
21
+ # Optional daemon handshake; called once detection is established.
22
+ attr_writer :ready_callback
23
+
21
24
  # @param root [String, Pathname] directory to watch
22
25
  # @param ignored [Array<String>] directory names/prefixes to skip
23
26
  # @param interval [Float] seconds between scans
24
27
  # @param sleeper [#call] injected for specs, so they need not pass time
25
- def initialize(root:, ignored: Watcher::DEFAULT_IGNORED_DIRECTORIES, interval: 1.0,
28
+ def initialize(root:, ignored: Watcher::DEFAULT_IGNORED_DIRECTORIES, interval: Watcher::DEFAULT_POLL_INTERVAL,
26
29
  sleeper: ->(seconds) { sleep(seconds) })
27
30
  @root = root.to_s
28
31
  @ignored = ignored
@@ -47,6 +50,7 @@ module Woods
47
50
 
48
51
  @running = true
49
52
  primed_now?
53
+ @ready_callback&.call
50
54
 
51
55
  while @running
52
56
  @sleeper.call(@interval)
@@ -31,9 +31,10 @@ module Woods
31
31
  STATES = %i[running degraded stopped].freeze
32
32
 
33
33
  # A status record older than this is not believed, however healthy it
34
- # claims to be. Generous relative to a debounce window, because an idle
35
- # daemon only rewrites its status when something happens.
34
+ # claims to be. The daemon heartbeats every five minutes, including
35
+ # while idle, so this allows two missed heartbeats.
36
36
  STALE_AFTER = 900 # 15 minutes
37
+ MAX_FUTURE_SKEW = 30 # seconds of clock difference tolerated across hosts
37
38
 
38
39
  # @param output_dir [String, Pathname] index directory
39
40
  # @param clock [#call] returns the ISO8601 stamp for a write
@@ -86,31 +87,34 @@ module Woods
86
87
 
87
88
  # Is a daemon currently maintaining this index?
88
89
  #
89
- # Three things have to hold, and each rules out a different way the
90
- # status file lies: the state has to be one a live daemon writes, the
91
- # recorded pid has to still exist (a `kill -9` leaves the file behind),
92
- # and the record has to be recent (a machine that lost power leaves a
93
- # `running` record with a pid some unrelated process now owns).
90
+ # Require a live state, a positive pid, and a recent timestamp. A local
91
+ # pid must still exist; foreign-host trust explicitly substitutes bounded
92
+ # heartbeat freshness for that uncheckable process evidence. A crashed
93
+ # foreign daemon can therefore remain believable until the record ages out.
94
94
  #
95
95
  # Callers use this to decide whether to do the work themselves — a
96
96
  # session-start hook that would otherwise run `woods:incremental` can
97
97
  # skip it when a daemon is already on the job.
98
98
  #
99
99
  # @param max_age [Numeric] seconds after which a record is disbelieved
100
+ # @param trust_foreign_host [Boolean] trust a fresh foreign-host record
101
+ # without a local pid check; defaults to WOODS_WATCH_TRUST_FOREIGN_HOST=1
100
102
  # @return [Boolean]
101
- def alive?(max_age: STALE_AFTER)
103
+ def alive?(max_age: STALE_AFTER, trust_foreign_host: ENV['WOODS_WATCH_TRUST_FOREIGN_HOST'] == '1')
102
104
  record = read
103
105
  return false unless %w[running degraded].include?(record['state'])
104
- return false unless same_host?(record['host'])
105
- return false unless process_alive?(record['pid'])
106
+ return false unless record['pid'].is_a?(Integer) && record['pid'].positive?
107
+ return false unless recent?(record['updated_at'], max_age)
108
+ return trust_foreign_host unless same_host?(record['host'])
106
109
 
107
- recent?(record['updated_at'], max_age)
110
+ process_alive?(record['pid'])
108
111
  end
109
112
 
110
113
  # The identity a pid is only meaningful within.
111
114
  #
112
115
  # In a container the hostname defaults to the container id, so this
113
- # changes exactly when the pid namespace does.
116
+ # usually changes with the pid namespace. Custom or reused hostnames
117
+ # cannot establish namespace identity.
114
118
  #
115
119
  # @return [String]
116
120
  def self.host_identity
@@ -128,8 +132,8 @@ module Woods
128
132
  # deployment. A container pid like 47 almost always exists on the host, so
129
133
  # a host hook would read `running` plus a live-looking pid plus a fresh
130
134
  # timestamp and stand down while nothing was covering it. Comparing the
131
- # recorded host means a cross-namespace reader disbelieves the record
132
- # rather than misreading it.
135
+ # recorded host keeps a cross-namespace reader from checking an unrelated
136
+ # local pid. Foreign records are rejected unless freshness trust is enabled.
133
137
  #
134
138
  # Records written before this field existed have no host; treat them as
135
139
  # same-host so an in-place upgrade does not declare a live daemon dead.
@@ -160,7 +164,8 @@ module Woods
160
164
  def recent?(iso8601, max_age)
161
165
  return false if iso8601.nil?
162
166
 
163
- Time.parse(@clock.call) - Time.parse(iso8601) <= max_age
167
+ age = Time.iso8601(@clock.call) - Time.iso8601(iso8601)
168
+ age.between?(-MAX_FUTURE_SKEW, max_age)
164
169
  rescue ArgumentError, TypeError
165
170
  false
166
171
  end
@@ -50,14 +50,14 @@ module Woods
50
50
  def each_file(root:, ignored:, visited: nil, &block)
51
51
  base = root.to_s
52
52
  prefix = "#{base}/"
53
- visited ||= Set.new
53
+ visited = (visited || Set.new).dup
54
54
  visited << real_dir(base)
55
55
 
56
56
  Find.find(base) do |path|
57
57
  next if path == base
58
58
 
59
59
  skip = skip?(path.delete_prefix(prefix), ignored)
60
- Find.prune if visit_entry(path, skip, ignored, visited, &block) == :prune
60
+ Find.prune if visit_entry(path, skip, ignored, visited, root: base, &block) == :prune
61
61
  end
62
62
  rescue Errno::ENOENT
63
63
  # The root vanished mid-walk (a worktree removed under us). Nothing to
@@ -68,7 +68,7 @@ module Woods
68
68
  # Classify one entry and act on it.
69
69
  #
70
70
  # @return [Symbol, nil] `:prune` when the caller should not descend
71
- def visit_entry(path, skip, ignored, visited, &block)
71
+ def visit_entry(path, skip, ignored, visited, root:, &block)
72
72
  # `Find` stats with `lstat`, so it neither descends a symlinked
73
73
  # directory nor reports one as a file — the entry would just vanish. A
74
74
  # full extraction globs, and `Dir.glob` *does* follow them, so leaving
@@ -76,7 +76,7 @@ module Woods
76
76
  # symlinked `app/models`, or a monorepo linking shared code in, simply
77
77
  # never produced an event.
78
78
  if symlinked_directory?(path)
79
- descend_symlink(path, ignored, visited, &block) unless skip
79
+ descend_symlink(path, ignored, visited | directory_ancestors(path, root), &block) unless skip
80
80
  nil
81
81
  elsif File.directory?(path)
82
82
  skip ? :prune : nil
@@ -86,19 +86,27 @@ module Woods
86
86
  end
87
87
  end
88
88
 
89
- # Walk a symlinked directory, once.
90
- #
91
- # The `visited` set is keyed on the *resolved* path, which is what makes
92
- # this terminate: a link pointing at its own ancestor is an infinite tree
93
- # to any walker that follows links naively, and two links pointing at one
94
- # directory would otherwise yield every file under it twice — enough to
95
- # make a change look like two changes to the caller diffing walks.
89
+ # Detect cycles along this logical traversal branch, not across siblings.
90
+ # Two aliases of one target are distinct extraction paths: suppressing
91
+ # one can leave only an ignored alias and hide the relevant app path.
92
+ # Find does not expose exit events for ordinary directories, so collect
93
+ # their ancestors at each symlink rather than retaining a global set.
94
+ def directory_ancestors(path, root)
95
+ ancestors = Set.new
96
+ directory = File.dirname(path)
97
+ loop do
98
+ ancestors << real_dir(directory)
99
+ break if directory == root || File.dirname(directory) == directory
100
+
101
+ directory = File.dirname(directory)
102
+ end
103
+ ancestors
104
+ end
105
+
96
106
  def descend_symlink(path, ignored, visited, &block)
97
107
  target = real_dir(path)
98
108
  return if target.nil? || visited.include?(target)
99
109
 
100
- visited << target
101
-
102
110
  # Walk the *resolved* directory — `Find.find` lstats even the root it is
103
111
  # handed, so pointing it at the link itself yields the link and stops —
104
112
  # then rewrite each result back under the link. Callers compare these
@@ -27,6 +27,9 @@ module Woods
27
27
  # Every backend implements the same two methods: `start(&on_change)`,
28
28
  # which yields an array of absolute paths, and `stop`.
29
29
  module Watcher
30
+ # Seconds of sleep between polling scans.
31
+ DEFAULT_POLL_INTERVAL = 1.0
32
+
30
33
  # Directory names never worth watching. Scanning them is what makes a
31
34
  # polling watcher expensive, and a change inside one is never extraction
32
35
  # input.
@@ -52,7 +55,7 @@ module Woods
52
55
  # @param force_polling [Boolean] skip the listen backend entirely
53
56
  # @param logger [#info, #warn] where backend selection is reported
54
57
  # @return [#start, #stop]
55
- def build(root:, ignored: DEFAULT_IGNORED_DIRECTORIES, poll_interval: 1.0,
58
+ def build(root:, ignored: DEFAULT_IGNORED_DIRECTORIES, poll_interval: DEFAULT_POLL_INTERVAL,
56
59
  force_polling: false, logger: nil)
57
60
  force_polling ||= containerized?
58
61
 
data/lib/woods.rb CHANGED
@@ -135,7 +135,7 @@ module Woods
135
135
  :concurrent_extraction, :precompute_flows, :extract_navigation_edges, :enable_snapshots,
136
136
  :session_tracer_enabled, :session_tracer_allow_production,
137
137
  :session_store, :session_id_proc, :session_exclude_paths,
138
- :console_mcp_enabled, :console_mcp_path, :console_mcp_token,
138
+ :console_mcp_enabled, :console_mcp_http_enabled, :console_mcp_path, :console_mcp_token,
139
139
  :console_mcp_allowed_origins,
140
140
  :console_redacted_columns,
141
141
  :console_redacted_key_values, :console_embedded_read_tools,
@@ -147,13 +147,15 @@ module Woods
147
147
  :unblocked_api_token, :unblocked_collection_id, :unblocked_repo_url,
148
148
  :cache_store, :cache_options,
149
149
  :dump_retention_count, :component_paths
150
- attr_reader :embedding_model, :max_context_tokens, :similarity_threshold, :extractors, :pretty_json,
151
- :context_format, :cache_enabled, :volatile_dependency_ratio,
150
+ attr_reader :retrieval_mode, :embedding_model, :max_context_tokens, :similarity_threshold, :extractors,
151
+ :pretty_json,
152
+ :context_format, :cache_enabled, :volatile_dependency_ratio, :volatile_dependency_limit_per_target,
152
153
  :graph_cycle_limit, :graph_cycle_max_length,
153
154
  :incremental_blast_radius_depth, :durable_payload_writes
154
155
 
155
156
  def initialize # rubocop:disable Metrics/MethodLength
156
157
  @output_dir = nil # Resolved lazily; Rails.root is nil at require time
158
+ @retrieval_mode = :semantic
157
159
  @embedding_model = 'text-embedding-3-small'
158
160
  @embedding_model_explicit = false
159
161
  @max_context_tokens = 8000
@@ -173,10 +175,13 @@ module Woods
173
175
  @session_id_proc = nil
174
176
  @session_exclude_paths = []
175
177
  @console_mcp_enabled = false
178
+ # Preserve existing HTTP setups. Stdio-only hosts explicitly disable
179
+ # HTTP while leaving the Console master switch enabled.
180
+ @console_mcp_http_enabled = true
176
181
  @console_mcp_path = '/mcp/console'
177
182
  # Accept token from config or env var. Nil by default — the railtie
178
- # refuses to wire the middleware in production without a real token
179
- # and only warns loudly in non-prod when unset.
183
+ # rejects production boot when HTTP Console is enabled without a real
184
+ # token, and warns in non-production. Stdio does not use this token.
180
185
  @console_mcp_token = ENV.fetch('WOODS_CONSOLE_MCP_TOKEN', nil)
181
186
  # Origins allowed to reach the embedded console MCP. Loopback only
182
187
  # by default; override in host initializers for tunneled or internal
@@ -209,6 +214,7 @@ module Woods
209
214
  @cache_options = {} # { redis: client, cache: store, ttl: { embeddings: 86400, ... } }
210
215
  @dump_retention_count = 3
211
216
  @volatile_dependency_ratio = 3.0
217
+ @volatile_dependency_limit_per_target = nil
212
218
  @graph_cycle_limit = GraphAnalyzer::DEFAULT_CYCLE_LIMIT
213
219
  @graph_cycle_max_length = GraphAnalyzer::DEFAULT_CYCLE_MAX_LENGTH
214
220
  # nil = the unbounded transitive closure. See the setter for why the
@@ -255,6 +261,7 @@ module Woods
255
261
  @max_context_tokens = value
256
262
  end
257
263
 
264
+ # Deprecated compatibility setting; retrieval never applies this cutoff.
258
265
  # @param value [Numeric] Must be between 0.0 and 1.0 inclusive
259
266
  # @raise [ConfigurationError] if value is out of range or not numeric
260
267
  def similarity_threshold=(value)
@@ -265,6 +272,8 @@ module Woods
265
272
  raise ConfigurationError, "similarity_threshold must be between 0.0 and 1.0, got #{value.inspect}"
266
273
  end
267
274
 
275
+ warn '[woods] similarity_threshold is deprecated and does not filter retrieval results; ' \
276
+ 'use explicit types/scopes and inspect returned ranking evidence instead.'
268
277
  @similarity_threshold = float_val
269
278
  end
270
279
 
@@ -317,6 +326,16 @@ module Woods
317
326
  @pretty_json = value
318
327
  end
319
328
 
329
+ # @param value [Symbol, String] semantic (default) or lexical
330
+ # @raise [ConfigurationError] if value is not a supported retrieval mode
331
+ def retrieval_mode=(value)
332
+ unless %w[semantic lexical].include?(value.to_s)
333
+ raise ConfigurationError, "retrieval_mode must be semantic or lexical, got #{value.inspect}"
334
+ end
335
+
336
+ @retrieval_mode = value.to_sym
337
+ end
338
+
320
339
  # @param value [Symbol] Must be one of :claude, :markdown, :plain, :json
321
340
  # @raise [ConfigurationError] if value is not a valid format
322
341
  def context_format=(value)
@@ -348,6 +367,16 @@ module Woods
348
367
  @volatile_dependency_ratio = value.to_f
349
368
  end
350
369
 
370
+ # Maximum volatile report edges per typed dependency, before the global
371
+ # report limit. `nil` preserves the unlimited default.
372
+ #
373
+ # @param value [Integer, nil] must be a positive Integer, or nil
374
+ # @raise [ConfigurationError] otherwise
375
+ def volatile_dependency_limit_per_target=(value)
376
+ @volatile_dependency_limit_per_target = validate_optional_positive_integer!(:volatile_dependency_limit_per_target,
377
+ value)
378
+ end
379
+
351
380
  # How many distinct cycles {Woods::GraphAnalyzer#cycles} enumerates before
352
381
  # it stops. `nil` removes the cap and restores exhaustive enumeration.
353
382
  #
@@ -493,11 +522,8 @@ module Woods
493
522
  # @param output_dir [String] Override output directory
494
523
  # @return [Hash] Extraction results
495
524
  def extract!(output_dir: nil)
496
- require_relative 'woods/extractor'
497
-
498
525
  dir = output_dir || configuration.output_dir
499
- extractor = Extractor.new(output_dir: dir)
500
- extractor.extract_all
526
+ with_extraction_writer(dir, &:extract_all)
501
527
  end
502
528
 
503
529
  # Perform incremental extraction
@@ -505,10 +531,23 @@ module Woods
505
531
  # @param changed_files [Array<String>] List of changed files
506
532
  # @return [Array<String>] Re-extracted unit identifiers
507
533
  def extract_changed!(changed_files)
534
+ with_extraction_writer(configuration.output_dir) { |extractor| extractor.extract_changed(changed_files) }
535
+ end
536
+
537
+ private
538
+
539
+ # One-shot public callers share the task/watch writer lock. Unlike a rake
540
+ # task, a library caller needs a typed timeout exception, not process exit.
541
+ def with_extraction_writer(output_dir)
508
542
  require_relative 'woods/extractor'
543
+ require_relative 'woods/rake_helpers'
509
544
 
510
- extractor = Extractor.new(output_dir: configuration.output_dir)
511
- extractor.extract_changed(changed_files)
545
+ RakeHelpers.woods_with_extraction_lock(output_dir, raise_on_timeout: true) do
546
+ extractor = Extractor.new(output_dir: output_dir)
547
+ result = yield extractor
548
+ extractor.raise_on_publication_failure!
549
+ result
550
+ end
512
551
  end
513
552
  end
514
553
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "woods-plugin",
3
3
  "description": "Woods user guides for Claude Code: set up, upgrade, configure MCP servers for, investigate codebases with, enable a repository's agents on, and diagnose the Woods Rails code-intelligence gem.",
4
- "version": "2.3.0",
4
+ "version": "2.3.47",
5
5
  "author": {
6
6
  "name": "lost-in-the"
7
7
  },
@@ -0,0 +1,15 @@
1
+ def valid_string:
2
+ type == "string" and length > 0 and utf8bytelength <= 4096 and (contains("\u0000") | not);
3
+ def invalid: error("Unsupported or malformed edit event; no refresh queued.");
4
+ . as $input |
5
+ if type != "object" then invalid
6
+ elif $client == "claude" then
7
+ if .hook_event_name == "PostToolUse" and (["Write", "Edit", "MultiEdit"] | index($input.tool_name)) then
8
+ {version:1,root:.cwd,events:[{path:.tool_input.file_path,operation:"update"}]}
9
+ else invalid end
10
+ elif $client == "opencode" and .version == 1 and .client == "opencode" then
11
+ {version:1,root:.root,events:.events}
12
+ else invalid end
13
+ | if (.root | valid_string) and (.root | startswith("/")) and (.events | type == "array" and length > 0 and length <= 1000)
14
+ and all(.events[]; type == "object" and (.path | valid_string) and (.operation as $operation | ["add","update","delete"] | index($operation)))
15
+ then .events |= (map({path,operation}) | unique_by(.path,.operation)) else invalid end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ # Standalone stdlib fallback for hosts without jq; never loads the Woods bundle.
6
+ module WoodsHookEvent
7
+ module_function
8
+
9
+ def string?(value)
10
+ value.is_a?(String) && !value.empty? && value.bytesize <= 4096 && !value.include?("\0") && value.valid_encoding?
11
+ end
12
+
13
+ def normalize(client, input)
14
+ raise ArgumentError unless input.is_a?(Hash)
15
+
16
+ root, events = client_event(client, input)
17
+ validate_envelope(root, events)
18
+
19
+ normalized = events.map { |event| normalize_event(event) }.uniq
20
+ { 'version' => 1, 'root' => root,
21
+ 'events' => normalized.sort_by { |event| event.values_at('path', 'operation') } }
22
+ end
23
+
24
+ def validate_envelope(root, events)
25
+ raise ArgumentError unless string?(root) && root.start_with?('/')
26
+ raise ArgumentError unless events.is_a?(Array) && events.size.between?(1, 1000)
27
+ end
28
+
29
+ def client_event(client, input)
30
+ case client
31
+ when 'claude' then claude_event(input)
32
+ when 'opencode'
33
+ raise ArgumentError unless input['version'] == 1 && input['client'] == 'opencode'
34
+
35
+ [input['root'], input['events']]
36
+ else raise ArgumentError
37
+ end
38
+ end
39
+
40
+ def claude_event(input)
41
+ raise ArgumentError unless input['hook_event_name'] == 'PostToolUse' &&
42
+ %w[Write Edit MultiEdit].include?(input['tool_name']) &&
43
+ input['tool_input'].is_a?(Hash)
44
+
45
+ [input['cwd'], [{ 'path' => input.fetch('tool_input').fetch('file_path'), 'operation' => 'update' }]]
46
+ end
47
+
48
+ def normalize_event(event)
49
+ raise ArgumentError unless event.is_a?(Hash) && string?(event['path']) &&
50
+ %w[add update delete].include?(event['operation'])
51
+
52
+ { 'path' => event['path'], 'operation' => event['operation'] }
53
+ end
54
+ end
55
+
56
+ if $PROGRAM_NAME == __FILE__
57
+ begin
58
+ puts JSON.generate(WoodsHookEvent.normalize(ARGV.fetch(0), JSON.parse($stdin.read(1_048_577))))
59
+ rescue JSON::ParserError, ArgumentError, KeyError, TypeError
60
+ warn '[Woods hooks] Unsupported or malformed edit event; no refresh queued.'
61
+ exit 1
62
+ end
63
+ end
@@ -11,6 +11,16 @@
11
11
  "timeout": 600
12
12
  }
13
13
  ]
14
+ },
15
+ {
16
+ "matcher": "Edit|Write|MultiEdit",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/woods-context.sh\" PostToolUse",
21
+ "timeout": 2
22
+ }
23
+ ]
14
24
  }
15
25
  ],
16
26
  "SessionStart": [
@@ -23,6 +33,16 @@
23
33
  "timeout": 20
24
34
  }
25
35
  ]
36
+ },
37
+ {
38
+ "matcher": "startup|resume",
39
+ "hooks": [
40
+ {
41
+ "type": "command",
42
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/woods-context.sh\" SessionStart",
43
+ "timeout": 2
44
+ }
45
+ ]
26
46
  }
27
47
  ]
28
48
  }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ # Independently opt-in synchronous hints. The process deadline includes stdin,
3
+ # bundle startup, index/cache preparation, source verification and state I/O.
4
+ set -u
5
+ [ "${WOODS_HOOKS_DISABLED:-0}" = 1 ] && exit 0
6
+ [ "${WOODS_HOOK_CONTEXT_ENABLED:-0}" = 1 ] || exit 0
7
+ case "${1:-}" in SessionStart|PostToolUse) ;; *) exit 0 ;; esac
8
+ exec 3<&0
9
+ capture_response() {
10
+ runner=""; timer=""
11
+ cleanup() {
12
+ [ -z "$runner" ] || kill -KILL -- "-$runner" 2>/dev/null || true
13
+ [ -z "$timer" ] || kill -KILL -- "-$timer" 2>/dev/null || true
14
+ }
15
+ trap cleanup EXIT
16
+ trap 'exit 1' INT TERM
17
+ set -m
18
+ command="${WOODS_HOOK_CONTEXT_COMMAND:-bundle exec woods-hook-context}"
19
+ ( $command "$1" ) <&3 2>/dev/null &
20
+ runner=$!
21
+ ( sleep 0.85; kill -KILL -- "-$runner" 2>/dev/null ) >/dev/null 2>&1 &
22
+ timer=$!
23
+ wait "$runner" 2>/dev/null
24
+ status=$?
25
+ cleanup
26
+ wait "$timer" 2>/dev/null || true
27
+ runner=""; timer=""
28
+ # Carry the producer status in-band: Bash 3.2 cannot wait on this
29
+ # process-substitution PID. Cleanup finishes before the final status frame.
30
+ printf '\n%s' "$status"
31
+ }
32
+ # Bash 3.2-compatible process substitution: buffer the bounded response until
33
+ # EOF and successful producer exit. Never forward a timed-out partial envelope.
34
+ exec 4< <(capture_response "$1")
35
+ supervisor=$!
36
+ trap 'kill -TERM "$supervisor" 2>/dev/null; exit 0' INT TERM
37
+ response=""
38
+ # <=2048 payload bytes + newline/status byte; one extra byte rejects overflow.
39
+ LC_ALL=C IFS= read -r -d '' -n 2051 response <&4
40
+ read_status=$?
41
+ exec 4<&-
42
+ if [ "$read_status" -ne 1 ]; then
43
+ kill -TERM "$supervisor" 2>/dev/null || true
44
+ exit 0
45
+ fi
46
+ case "$response" in *$'\n'0) response="${response%$'\n'0}" ;; *) exit 0 ;; esac
47
+ wait "$supervisor" 2>/dev/null || true
48
+ # One builtin write of an already complete <=2KiB envelope to the client pipe.
49
+ printf '%s' "$response"
50
+ exit 0