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
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.36",
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
@@ -0,0 +1,159 @@
1
+ # Generated by script/generate-hook-rules; do not edit.
2
+ woods_input_action() {
3
+ local path="$1" operation="${2:-update}"
4
+ if [[ "$path" == Gemfile ]]; then printf full; return; fi
5
+ if [[ "$path" == Gemfile.lock ]]; then printf full; return; fi
6
+ if [[ "$path" == .ruby-version ]]; then printf full; return; fi
7
+ if [[ "$path" == config/application.rb ]]; then printf full; return; fi
8
+ if [[ "$path" == config/boot.rb ]]; then printf full; return; fi
9
+ if [[ "$path" == config/environment.rb ]]; then printf full; return; fi
10
+ if [[ "$path" == config/database.yml ]]; then printf full; return; fi
11
+ if [[ "$path" == config/credentials.yml.enc ]]; then printf full; return; fi
12
+ if [[ "$path" == config/master.key ]]; then printf full; return; fi
13
+ if [[ "$path" == db/schema.rb ]]; then printf full; return; fi
14
+ if [[ "$path" == db/structure.sql ]]; then printf full; return; fi
15
+ if [[ "$path" == config/initializers/* ]]; then printf full; return; fi
16
+ if [[ "$path" == config/environments/* ]]; then printf full; return; fi
17
+ if [[ "$path" == config/credentials/* ]]; then printf full; return; fi
18
+ if [[ "$path" =~ ^config/settings\.ya?ml$ ]]; then printf full; return; fi
19
+ if [[ "$path" =~ ^config/settings/[^/]+\.ya?ml$ ]]; then printf full; return; fi
20
+ if [[ "$path" =~ ^config/(cable|storage|sidekiq|puma|cache|queue)\.ya?ml$ ]]; then printf full; return; fi
21
+ if [[ "$path" =~ ^\.env(\..+)?$ ]]; then printf full; return; fi
22
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/* ]]; }; }; }; then
23
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
24
+ return
25
+ fi
26
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/services/* ]] || [[ "$path" == app/interactors/* ]] || [[ "$path" == app/operations/* ]] || [[ "$path" == app/commands/* ]] || [[ "$path" == app/use_cases/* ]]; }; }; }; then
27
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
28
+ return
29
+ fi
30
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/jobs/* ]] || [[ "$path" == app/workers/* ]] || [[ "$path" == app/sidekiq/* ]]; }; }; }; then
31
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
32
+ return
33
+ fi
34
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/serializers/* ]] || [[ "$path" == app/blueprinters/* ]] || [[ "$path" == app/decorators/* ]]; }; }; }; then
35
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
36
+ return
37
+ fi
38
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/managers/* ]]; }; }; }; then
39
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
40
+ return
41
+ fi
42
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/policies/* ]]; }; }; }; then
43
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
44
+ return
45
+ fi
46
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/validators/* ]]; }; }; }; then
47
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
48
+ return
49
+ fi
50
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/policies/* ]]; }; }; }; then
51
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
52
+ return
53
+ fi
54
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/decorators/* ]] || [[ "$path" == app/presenters/* ]] || [[ "$path" == app/form_objects/* ]]; }; }; }; then
55
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
56
+ return
57
+ fi
58
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == config/initializers/* ]] || [[ "$path" == config/environments/* ]]; }; }; }; then
59
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
60
+ return
61
+ fi
62
+ if { { { [[ "$path" == *.rb ]]; } && [[ "$path" == */concerns/* ]] && { [[ "$path" == app/* ]]; }; }; }; then
63
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
64
+ return
65
+ fi
66
+ if { { [[ "$path" != */concerns/* ]] && { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/* ]] || [[ "$path" == lib/* ]]; }; }; }; then
67
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
68
+ return
69
+ fi
70
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/graphql/* ]]; }; }; }; then
71
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
72
+ return
73
+ fi
74
+ if { { { [[ "$path" == *.yml ]]; } && { [[ "$path" == config/locales/* ]]; }; }; }; then
75
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
76
+ return
77
+ fi
78
+ if { { { [[ "$path" == *.rake ]]; } && { [[ "$path" == lib/tasks/* ]]; }; }; }; then
79
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
80
+ return
81
+ fi
82
+ if { { { [[ "$path" == *.html.erb ]] || [[ "$path" == *.erb ]]; } && { [[ "$path" == app/views/* ]]; }; }; }; then
83
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
84
+ return
85
+ fi
86
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "${path%/*}" == db/migrate ]]; }; }; }; then
87
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
88
+ return
89
+ fi
90
+ if { { [[ "$path" != */concerns/* ]] && { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/models/* ]]; }; }; }; then
91
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
92
+ return
93
+ fi
94
+ if { { [[ "$path" != */tasks/* ]] && [[ "$path" != */generators/* ]] && { [[ "$path" == *.rb ]]; } && { [[ "$path" == lib/* ]]; }; }; }; then
95
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
96
+ return
97
+ fi
98
+ if { { { [[ "$path" == *_spec.rb ]]; } && { [[ "$path" == spec/* ]]; }; }; }; then
99
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
100
+ return
101
+ fi
102
+ if { { { [[ "$path" == *_test.rb ]]; } && { [[ "$path" == test/* ]]; }; }; }; then
103
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
104
+ return
105
+ fi
106
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/controllers/* ]]; }; }; }; then
107
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
108
+ return
109
+ fi
110
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/models/* ]]; }; }; }; then
111
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
112
+ return
113
+ fi
114
+ if { { { [[ "$path" == *.erb ]]; } && { [[ "$path" == app/views/* ]]; }; }; }; then
115
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
116
+ return
117
+ fi
118
+ if { [[ "$path" == config/routes.rb ]] || { { [[ "$path" == config/routes/* ]]; }; }; }; then
119
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
120
+ return
121
+ fi
122
+ if { [[ "$path" == config/routes.rb ]] || [[ "$path" == Gemfile.lock ]] || { { [[ "$path" == config/routes/* ]]; }; }; }; then
123
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
124
+ return
125
+ fi
126
+ if { [[ "$path" == config/application.rb ]] || [[ "$path" == Gemfile.lock ]] || { { [[ "$path" == config/initializers/* ]] || [[ "$path" == config/environments/* ]]; }; }; }; then
127
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
128
+ return
129
+ fi
130
+ if { [[ "$path" == config/recurring.yml ]] || [[ "$path" == config/sidekiq_cron.yml ]] || [[ "$path" == config/schedule.rb ]] || { false; }; }; then
131
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
132
+ return
133
+ fi
134
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/models/* ]]; }; }; }; then
135
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
136
+ return
137
+ fi
138
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == spec/factories/* ]] || [[ "$path" == test/factories/* ]]; }; }; }; then
139
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
140
+ return
141
+ fi
142
+ if { { { [[ "$path" == *.sql ]]; } && { [[ "$path" == db/views/* ]]; }; }; }; then
143
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
144
+ return
145
+ fi
146
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/* ]]; }; }; }; then
147
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
148
+ return
149
+ fi
150
+ if { [[ "$path" == Gemfile.lock ]] || { false; }; }; then
151
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
152
+ return
153
+ fi
154
+ if { { [[ "$path" != *node_modules/* ]] && [[ "$path" != *vendor/* ]] && [[ "$path" != *tmp/* ]] && [[ "$path" != *bin/* ]] && [[ "$path" != *script/* ]] && { [[ "${path##*/}" == package.yml ]] || [[ "${path##*/}" == packwerk.yml ]]; }; }; }; then
155
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
156
+ return
157
+ fi
158
+ printf ignore
159
+ }
@@ -0,0 +1,65 @@
1
+ // OpenCode 1.18.27 tool.execute.after adapter. Keep callback metadata private:
2
+ // only the worktree and affected paths cross into the shared refresh runner.
3
+ import { spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+
7
+ const runner = fileURLToPath(new URL("./woods-refresh.sh", import.meta.url));
8
+ const enabled = () => process.env.WOODS_HOOKS_ENABLED === "1" && process.env.WOODS_HOOKS_DISABLED !== "1";
9
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
10
+ const pathString = (value) => typeof value === "string" && value.length > 0 && !value.includes("\0");
11
+
12
+ function eventsFor(input, output) {
13
+ if (!object(input) || !object(output) || !object(output.metadata)) throw new Error("invalid event");
14
+ const metadata = output.metadata;
15
+ if (input.tool === "apply_patch") {
16
+ if (!Array.isArray(metadata.files) || !metadata.files.length || metadata.files.length > 1000) throw new Error("missing file metadata");
17
+ return metadata.files.flatMap((file) => {
18
+ if (!object(file) || !pathString(file.filePath)) throw new Error("invalid file metadata");
19
+ if (file.type === "move") {
20
+ if (!pathString(file.movePath)) throw new Error("missing move target");
21
+ return [{ path: file.filePath, operation: "delete" }, { path: file.movePath, operation: "add" }];
22
+ }
23
+ if (!["add", "update", "delete"].includes(file.type)) throw new Error("unknown file operation");
24
+ return [{ path: file.filePath, operation: file.type }];
25
+ });
26
+ }
27
+ if (input.tool === "write") {
28
+ if (!pathString(metadata.filepath) || typeof metadata.exists !== "boolean") throw new Error("invalid write metadata");
29
+ return [{ path: metadata.filepath, operation: metadata.exists ? "update" : "add" }];
30
+ }
31
+ if (input.tool === "edit") {
32
+ if (!object(metadata.filediff) || !pathString(metadata.filediff.file)) throw new Error("invalid edit metadata");
33
+ return [{ path: metadata.filediff.file, operation: "update" }];
34
+ }
35
+ throw new Error("unsupported tool");
36
+ }
37
+
38
+ export default async function WoodsHooks({ directory, worktree }) {
39
+ return {
40
+ "tool.execute.after": async (input, output) => {
41
+ if (!enabled() || !["apply_patch", "write", "edit"].includes(input?.tool)) return;
42
+ try {
43
+ if (!pathString(directory) || !pathString(worktree) || !path.isAbsolute(directory) || !path.isAbsolute(worktree)) {
44
+ throw new Error("invalid project context");
45
+ }
46
+ const relative = path.relative(worktree, directory);
47
+ if (relative === ".." || relative.startsWith("../") || path.isAbsolute(relative)) throw new Error("foreign project");
48
+ const payload = JSON.stringify({ version: 1, client: "opencode", root: directory, events: eventsFor(input, output) });
49
+ if (Buffer.byteLength(payload) > 49_152) throw new Error("event too large");
50
+ // The shared runner durably queues and owns its own deadline. Tool
51
+ // completion only hands off the event; it does not claim refresh success.
52
+ await new Promise((resolve, reject) => {
53
+ const child = spawn("bash", [runner, "opencode"], {
54
+ cwd: directory, detached: true, stdio: ["pipe", "ignore", "inherit"],
55
+ });
56
+ child.once("error", reject);
57
+ child.stdin.once("error", reject);
58
+ child.stdin.end(payload, () => { child.unref(); resolve(); });
59
+ });
60
+ } catch {
61
+ console.error("[Woods hooks] Unsupported, malformed or undeliverable OpenCode edit event; refresh not confirmed.");
62
+ }
63
+ },
64
+ };
65
+ }