woods 2.0.0.beta1 → 2.0.0.beta3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +400 -1
- data/CONTRIBUTING.md +224 -9
- data/README.md +7 -3
- data/SECURITY.md +9 -6
- data/docs/AGENT_GUIDE.md +83 -4
- data/docs/AGENT_SETUP.md +82 -1
- data/docs/BACKEND_MATRIX.md +20 -0
- data/docs/CLIENT_HOOKS.md +111 -0
- data/docs/CONFIGURATION_REFERENCE.md +233 -13
- data/docs/CONSOLE_MCP_SETUP.md +35 -5
- data/docs/DOCKER_SETUP.md +21 -2
- data/docs/EVALUATION.md +464 -1
- data/docs/EXTRACTOR_REFERENCE.md +36 -5
- data/docs/FAQ.md +11 -12
- data/docs/GETTING_STARTED.md +17 -5
- data/docs/INCREMENTAL_EXTRACTION.md +158 -2
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +15 -7
- data/docs/MCP_SERVERS.md +221 -5
- data/docs/MCP_TOOL_COOKBOOK.md +33 -18
- data/docs/NOTION_INTEGRATION.md +13 -0
- data/docs/OBSIDIAN_INTEGRATION.md +57 -9
- data/docs/PUBLISHED_INDEX.md +71 -0
- data/docs/README.md +7 -0
- data/docs/RETRIEVAL_GUIDE.md +253 -11
- data/docs/RUNTIME_TRACING.md +71 -0
- data/docs/SOURCE_FRESHNESS.md +143 -0
- data/docs/TROUBLESHOOTING.md +117 -5
- data/docs/UNBLOCKED_INTEGRATION.md +25 -0
- data/docs/UPGRADING_TO_2.md +44 -22
- data/docs/WATCH_DAEMON.md +259 -59
- data/exe/woods-agent-config +6 -0
- data/exe/woods-extract +5 -0
- data/exe/woods-hook-context +6 -0
- data/lib/generators/woods/templates/woods.rb.tt +1 -3
- data/lib/tasks/woods.rake +47 -397
- data/lib/woods/agent_configuration/applier.rb +133 -0
- data/lib/woods/agent_configuration/cli.rb +101 -0
- data/lib/woods/agent_configuration/cli_options.rb +29 -0
- data/lib/woods/agent_configuration/document.rb +105 -0
- data/lib/woods/agent_configuration/error.rb +7 -0
- data/lib/woods/agent_configuration/launcher.rb +75 -0
- data/lib/woods/agent_configuration/layout.rb +59 -0
- data/lib/woods/agent_configuration/managed_section.rb +62 -0
- data/lib/woods/agent_configuration/plan.rb +98 -0
- data/lib/woods/agent_configuration/plan_diff.rb +38 -0
- data/lib/woods/agent_configuration/planned_files.rb +61 -0
- data/lib/woods/agent_configuration/planner.rb +63 -0
- data/lib/woods/agent_configuration/planner_validation.rb +77 -0
- data/lib/woods/agent_configuration/preflight.rb +100 -0
- data/lib/woods/agent_configuration/recovery.rb +49 -0
- data/lib/woods/ast/node.rb +2 -0
- data/lib/woods/ast/parser.rb +38 -5
- data/lib/woods/atomic_file.rb +133 -3
- data/lib/woods/builder.rb +21 -5
- data/lib/woods/cache/cache_middleware.rb +28 -7
- data/lib/woods/cache/cache_store.rb +4 -5
- data/lib/woods/change_set.rb +5 -4
- data/lib/woods/console/credential_index.rb +20 -2
- data/lib/woods/console/credential_scanner.rb +14 -14
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/embedded_executor.rb +1 -1
- data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
- data/lib/woods/console/rack_middleware.rb +22 -13
- data/lib/woods/console/server.rb +18 -16
- data/lib/woods/dependency_graph.rb +65 -13
- data/lib/woods/embedding/corpus.rb +94 -0
- data/lib/woods/embedding/indexer.rb +90 -46
- data/lib/woods/embedding/openai.rb +17 -6
- data/lib/woods/evaluation/ablation_executor.rb +6 -1
- data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
- data/lib/woods/export/typed_reader.rb +56 -0
- data/lib/woods/extractor.rb +557 -228
- data/lib/woods/extractors/action_cable_extractor.rb +3 -1
- data/lib/woods/extractors/behavioral_profile.rb +9 -7
- data/lib/woods/extractors/caching_extractor.rb +3 -1
- data/lib/woods/extractors/concern_extractor.rb +64 -6
- data/lib/woods/extractors/configuration_extractor.rb +7 -3
- data/lib/woods/extractors/controller_extractor.rb +13 -4
- data/lib/woods/extractors/database_view_extractor.rb +3 -1
- data/lib/woods/extractors/decorator_extractor.rb +3 -1
- data/lib/woods/extractors/engine_extractor.rb +3 -1
- data/lib/woods/extractors/event_extractor.rb +4 -2
- data/lib/woods/extractors/factory_extractor.rb +3 -1
- data/lib/woods/extractors/graphql_extractor.rb +8 -2
- data/lib/woods/extractors/i18n_extractor.rb +3 -1
- data/lib/woods/extractors/job_extractor.rb +6 -19
- data/lib/woods/extractors/lib_extractor.rb +3 -1
- data/lib/woods/extractors/mailer_extractor.rb +20 -5
- data/lib/woods/extractors/manager_extractor.rb +3 -1
- data/lib/woods/extractors/method_parameters.rb +53 -0
- data/lib/woods/extractors/middleware_argument.rb +65 -0
- data/lib/woods/extractors/middleware_extractor.rb +9 -3
- data/lib/woods/extractors/migration_extractor.rb +3 -1
- data/lib/woods/extractors/model_extractor.rb +39 -33
- data/lib/woods/extractors/package_extractor.rb +24 -4
- data/lib/woods/extractors/phlex_extractor.rb +3 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -1
- data/lib/woods/extractors/poro_extractor.rb +3 -1
- data/lib/woods/extractors/pundit_extractor.rb +3 -1
- data/lib/woods/extractors/rails_source_extractor.rb +4 -2
- data/lib/woods/extractors/rake_task_extractor.rb +4 -2
- data/lib/woods/extractors/route_extractor.rb +3 -1
- data/lib/woods/extractors/route_helper_resolver.rb +10 -33
- data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
- data/lib/woods/extractors/serializer_extractor.rb +4 -2
- data/lib/woods/extractors/service_extractor.rb +3 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
- data/lib/woods/extractors/shared_utility_methods.rb +27 -15
- data/lib/woods/extractors/source_nesting.rb +1 -1
- data/lib/woods/extractors/state_machine_extractor.rb +3 -1
- data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
- data/lib/woods/extractors/validator_extractor.rb +3 -1
- data/lib/woods/extractors/view_component_extractor.rb +3 -1
- data/lib/woods/extractors/view_template_extractor.rb +3 -1
- data/lib/woods/flow_assembler.rb +87 -8
- data/lib/woods/flow_precomputer.rb +44 -7
- data/lib/woods/gem_mapper.rb +2 -0
- data/lib/woods/git_history.rb +116 -0
- data/lib/woods/graph_analyzer.rb +195 -63
- data/lib/woods/hooks/context_cli.rb +54 -0
- data/lib/woods/hooks/context_event.rb +88 -0
- data/lib/woods/hooks/context_hint.rb +73 -0
- data/lib/woods/hooks/context_impact.rb +77 -0
- data/lib/woods/hooks/context_output.rb +47 -0
- data/lib/woods/hooks/context_state.rb +102 -0
- data/lib/woods/hooks/refresh.rb +79 -0
- data/lib/woods/hooks/rule_projection.rb +78 -0
- data/lib/woods/input_rules.rb +19 -0
- data/lib/woods/mcp/bearer_auth.rb +20 -12
- data/lib/woods/mcp/bootstrapper.rb +62 -0
- data/lib/woods/mcp/index_reader.rb +323 -160
- data/lib/woods/mcp/initialization_guidance.rb +27 -0
- data/lib/woods/mcp/origin_guard.rb +17 -9
- data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
- data/lib/woods/mcp/search_results.rb +74 -0
- data/lib/woods/mcp/server.rb +158 -37
- data/lib/woods/mcp/tool_contract.rb +2 -0
- data/lib/woods/mcp/tool_response_renderer.rb +25 -0
- data/lib/woods/mcp/traversal_evidence.rb +113 -0
- data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
- data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
- data/lib/woods/notion/exporter.rb +56 -17
- data/lib/woods/obsidian/destination_plan.rb +98 -0
- data/lib/woods/obsidian/name_mapper.rb +19 -3
- data/lib/woods/obsidian/note_builder.rb +19 -10
- data/lib/woods/obsidian/vault_exporter.rb +88 -32
- data/lib/woods/operator/pipeline_guard.rb +18 -13
- data/lib/woods/path_dispatcher.rb +7 -1
- data/lib/woods/payload_store.rb +29 -15
- data/lib/woods/railtie.rb +3 -3
- data/lib/woods/railtie_support.rb +12 -12
- data/lib/woods/rake_helpers.rb +392 -0
- data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
- data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
- data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
- data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
- data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
- data/lib/woods/resilience/index_validator.rb +112 -23
- data/lib/woods/retrieval/context_assembler.rb +50 -15
- data/lib/woods/retrieval/lexical_assembler.rb +73 -0
- data/lib/woods/retrieval/lexical_index.rb +119 -0
- data/lib/woods/retrieval/ranker.rb +4 -2
- data/lib/woods/retrieval/scope.rb +108 -0
- data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
- data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
- data/lib/woods/retrieval/search_executor.rb +86 -27
- data/lib/woods/retrieval/source_evidence.rb +200 -0
- data/lib/woods/retriever.rb +98 -22
- data/lib/woods/ruby_analyzer/trace_enricher.rb +80 -38
- data/lib/woods/session_tracer/middleware.rb +10 -12
- data/lib/woods/session_tracer/redis_store.rb +22 -6
- data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
- data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
- data/lib/woods/session_tracer/unit_resolver.rb +63 -0
- data/lib/woods/source_inputs/consumer_errors.rb +27 -0
- data/lib/woods/source_inputs/handoff.rb +102 -0
- data/lib/woods/source_inputs/launcher.rb +157 -0
- data/lib/woods/source_inputs/manifest.rb +124 -0
- data/lib/woods/source_inputs/private_key.rb +55 -0
- data/lib/woods/source_inputs/scanner.rb +171 -0
- data/lib/woods/source_inputs/scopes.rb +71 -0
- data/lib/woods/source_inputs/session.rb +214 -0
- data/lib/woods/source_inputs/status.rb +84 -0
- data/lib/woods/source_inputs/verifier.rb +107 -0
- data/lib/woods/storage/metadata_store.rb +25 -25
- data/lib/woods/storage/pgvector.rb +29 -8
- data/lib/woods/storage/qdrant.rb +17 -7
- data/lib/woods/storage/vector_store.rb +18 -6
- data/lib/woods/tasks.rb +3 -2
- data/lib/woods/temporal/json_snapshot_store.rb +29 -8
- data/lib/woods/unblocked/exporter.rb +59 -70
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/boot_snapshot.rb +52 -0
- data/lib/woods/watch/daemon.rb +136 -28
- data/lib/woods/watch/listen_watcher.rb +4 -0
- data/lib/woods/watch/polling_watcher.rb +5 -1
- data/lib/woods/watch/status.rb +20 -15
- data/lib/woods/watch/tree_scan.rb +21 -13
- data/lib/woods/watch/watcher.rb +4 -1
- data/lib/woods.rb +135 -11
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.jq +15 -0
- data/plugin/hooks/adapters/normalize.rb +63 -0
- data/plugin/hooks/hooks.json +20 -0
- data/plugin/hooks/woods-context.sh +50 -0
- data/plugin/hooks/woods-input-rules.sh +159 -0
- data/plugin/hooks/woods-opencode.mjs +65 -0
- data/plugin/hooks/woods-post-edit.sh +2 -225
- data/plugin/hooks/woods-refresh.sh +260 -0
- data/plugin/hooks/woods-session-start.sh +47 -55
- data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
- data/plugin/skills/woods-diagnose/SKILL.md +288 -1
- data/plugin/skills/woods-investigate/SKILL.md +106 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
- data/plugin/skills/woods-setup/SKILL.md +107 -6
- metadata +84 -5
data/lib/woods.rb
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
require_relative 'woods/version'
|
|
25
25
|
# Configuration's `component_paths` default lives with the discovery it feeds.
|
|
26
26
|
require_relative 'woods/extractors/component_discovery'
|
|
27
|
+
# Configuration defaults the cycle caps to the analyzer's own constants.
|
|
28
|
+
require_relative 'woods/graph_analyzer'
|
|
27
29
|
|
|
28
30
|
module Woods
|
|
29
31
|
class Error < StandardError; end
|
|
@@ -133,7 +135,7 @@ module Woods
|
|
|
133
135
|
:concurrent_extraction, :precompute_flows, :extract_navigation_edges, :enable_snapshots,
|
|
134
136
|
:session_tracer_enabled, :session_tracer_allow_production,
|
|
135
137
|
:session_store, :session_id_proc, :session_exclude_paths,
|
|
136
|
-
:console_mcp_enabled, :console_mcp_path, :console_mcp_token,
|
|
138
|
+
:console_mcp_enabled, :console_mcp_http_enabled, :console_mcp_path, :console_mcp_token,
|
|
137
139
|
:console_mcp_allowed_origins,
|
|
138
140
|
:console_redacted_columns,
|
|
139
141
|
:console_redacted_key_values, :console_embedded_read_tools,
|
|
@@ -145,11 +147,15 @@ module Woods
|
|
|
145
147
|
:unblocked_api_token, :unblocked_collection_id, :unblocked_repo_url,
|
|
146
148
|
:cache_store, :cache_options,
|
|
147
149
|
:dump_retention_count, :component_paths
|
|
148
|
-
attr_reader :embedding_model, :max_context_tokens, :similarity_threshold, :extractors,
|
|
149
|
-
:
|
|
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,
|
|
153
|
+
:graph_cycle_limit, :graph_cycle_max_length,
|
|
154
|
+
:incremental_blast_radius_depth, :durable_payload_writes
|
|
150
155
|
|
|
151
156
|
def initialize # rubocop:disable Metrics/MethodLength
|
|
152
157
|
@output_dir = nil # Resolved lazily; Rails.root is nil at require time
|
|
158
|
+
@retrieval_mode = :semantic
|
|
153
159
|
@embedding_model = 'text-embedding-3-small'
|
|
154
160
|
@embedding_model_explicit = false
|
|
155
161
|
@max_context_tokens = 8000
|
|
@@ -169,10 +175,13 @@ module Woods
|
|
|
169
175
|
@session_id_proc = nil
|
|
170
176
|
@session_exclude_paths = []
|
|
171
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
|
|
172
181
|
@console_mcp_path = '/mcp/console'
|
|
173
182
|
# Accept token from config or env var. Nil by default — the railtie
|
|
174
|
-
#
|
|
175
|
-
# and
|
|
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.
|
|
176
185
|
@console_mcp_token = ENV.fetch('WOODS_CONSOLE_MCP_TOKEN', nil)
|
|
177
186
|
# Origins allowed to reach the embedded console MCP. Loopback only
|
|
178
187
|
# by default; override in host initializers for tunneled or internal
|
|
@@ -205,6 +214,16 @@ module Woods
|
|
|
205
214
|
@cache_options = {} # { redis: client, cache: store, ttl: { embeddings: 86400, ... } }
|
|
206
215
|
@dump_retention_count = 3
|
|
207
216
|
@volatile_dependency_ratio = 3.0
|
|
217
|
+
@volatile_dependency_limit_per_target = nil
|
|
218
|
+
@graph_cycle_limit = GraphAnalyzer::DEFAULT_CYCLE_LIMIT
|
|
219
|
+
@graph_cycle_max_length = GraphAnalyzer::DEFAULT_CYCLE_MAX_LENGTH
|
|
220
|
+
# nil = the unbounded transitive closure. See the setter for why the
|
|
221
|
+
# default is not a small number.
|
|
222
|
+
@incremental_blast_radius_depth = nil
|
|
223
|
+
# false = payload files are written without a per-file fsync and the
|
|
224
|
+
# whole payload is flushed once before `generation.json` is written.
|
|
225
|
+
# See the setter.
|
|
226
|
+
@durable_payload_writes = false
|
|
208
227
|
# Directories the component extractors walk before reading `descendants`,
|
|
209
228
|
# relative to Rails.root. See Extractors::ComponentDiscovery.
|
|
210
229
|
@component_paths = Extractors::ComponentDiscovery::DEFAULT_COMPONENT_PATHS.dup
|
|
@@ -242,6 +261,7 @@ module Woods
|
|
|
242
261
|
@max_context_tokens = value
|
|
243
262
|
end
|
|
244
263
|
|
|
264
|
+
# Deprecated compatibility setting; retrieval never applies this cutoff.
|
|
245
265
|
# @param value [Numeric] Must be between 0.0 and 1.0 inclusive
|
|
246
266
|
# @raise [ConfigurationError] if value is out of range or not numeric
|
|
247
267
|
def similarity_threshold=(value)
|
|
@@ -252,6 +272,8 @@ module Woods
|
|
|
252
272
|
raise ConfigurationError, "similarity_threshold must be between 0.0 and 1.0, got #{value.inspect}"
|
|
253
273
|
end
|
|
254
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.'
|
|
255
277
|
@similarity_threshold = float_val
|
|
256
278
|
end
|
|
257
279
|
|
|
@@ -274,6 +296,29 @@ module Woods
|
|
|
274
296
|
@extractors = value
|
|
275
297
|
end
|
|
276
298
|
|
|
299
|
+
# Force an fsync on every payload file as it is written, on top of the
|
|
300
|
+
# single flush the publish already performs.
|
|
301
|
+
#
|
|
302
|
+
# Off by default, and off is not a weaker guarantee. Readers resolve only
|
|
303
|
+
# through `generation.json`; a payload file has no reader until that
|
|
304
|
+
# pointer names it, and {Woods::Extractor#sync_payload} makes the whole
|
|
305
|
+
# payload durable before the pointer is written. Turning this on buys
|
|
306
|
+
# exactly one thing: an individual payload file being durable before the
|
|
307
|
+
# pointer exists.
|
|
308
|
+
#
|
|
309
|
+
# It costs a lot for it. Two forced flushes per file, 8.9ms each on btrfs:
|
|
310
|
+
# 8000 units is 71s of writing against 1s.
|
|
311
|
+
#
|
|
312
|
+
# Turning it on cannot disable the publish flush. That flush *is* the
|
|
313
|
+
# durability contract, so it has no opt-out.
|
|
314
|
+
#
|
|
315
|
+
# @param value [Boolean]
|
|
316
|
+
# @raise [Woods::ConfigurationError] when value is not a boolean
|
|
317
|
+
def durable_payload_writes=(value)
|
|
318
|
+
validate_boolean!(:durable_payload_writes, value)
|
|
319
|
+
@durable_payload_writes = value
|
|
320
|
+
end
|
|
321
|
+
|
|
277
322
|
# @param value [Boolean] Must be true or false
|
|
278
323
|
# @raise [ConfigurationError] if value is not a boolean
|
|
279
324
|
def pretty_json=(value)
|
|
@@ -281,6 +326,16 @@ module Woods
|
|
|
281
326
|
@pretty_json = value
|
|
282
327
|
end
|
|
283
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
|
+
|
|
284
339
|
# @param value [Symbol] Must be one of :claude, :markdown, :plain, :json
|
|
285
340
|
# @raise [ConfigurationError] if value is not a valid format
|
|
286
341
|
def context_format=(value)
|
|
@@ -312,6 +367,56 @@ module Woods
|
|
|
312
367
|
@volatile_dependency_ratio = value.to_f
|
|
313
368
|
end
|
|
314
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
|
+
|
|
380
|
+
# How many distinct cycles {Woods::GraphAnalyzer#cycles} enumerates before
|
|
381
|
+
# it stops. `nil` removes the cap and restores exhaustive enumeration.
|
|
382
|
+
#
|
|
383
|
+
# @param value [Integer, nil] must be a positive Integer, or nil
|
|
384
|
+
# @raise [ConfigurationError] otherwise
|
|
385
|
+
def graph_cycle_limit=(value)
|
|
386
|
+
@graph_cycle_limit = validate_optional_positive_integer!(:graph_cycle_limit, value)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# The longest cycle {Woods::GraphAnalyzer#cycles} records, in distinct
|
|
390
|
+
# nodes. `nil` removes the cap.
|
|
391
|
+
#
|
|
392
|
+
# @param value [Integer, nil] must be a positive Integer, or nil
|
|
393
|
+
# @raise [ConfigurationError] otherwise
|
|
394
|
+
def graph_cycle_max_length=(value)
|
|
395
|
+
@graph_cycle_max_length = validate_optional_positive_integer!(:graph_cycle_max_length, value)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# How many reverse hops an incremental run walks from a changed file
|
|
399
|
+
# before it stops re-extracting dependents. `nil` (the default) keeps the
|
|
400
|
+
# unbounded transitive closure.
|
|
401
|
+
#
|
|
402
|
+
# A unit's extracted content is mostly a function of its own source and
|
|
403
|
+
# its own reflection, so on most graphs a small cap re-extracts the same
|
|
404
|
+
# bytes far faster. It is not safe everywhere, which is why the default
|
|
405
|
+
# is unbounded: an STI grandchild reads its grandparent's reflection
|
|
406
|
+
# (`reflect_on_all_associations`, `_validators` and the callback chain
|
|
407
|
+
# are all inherited), so `SportsCar < Car < Vehicle` has content that
|
|
408
|
+
# changes when `Vehicle` changes while sitting two hops away in the
|
|
409
|
+
# graph. A nested `has_many :through` resolves through the same kind of
|
|
410
|
+
# chain. Set this only on a tree you know has neither.
|
|
411
|
+
#
|
|
412
|
+
# @param value [Integer, nil] must be a positive Integer, or nil
|
|
413
|
+
# @raise [ConfigurationError] otherwise
|
|
414
|
+
def incremental_blast_radius_depth=(value)
|
|
415
|
+
@incremental_blast_radius_depth = validate_optional_positive_integer!(
|
|
416
|
+
:incremental_blast_radius_depth, value
|
|
417
|
+
)
|
|
418
|
+
end
|
|
419
|
+
|
|
315
420
|
# Accepted for forward compatibility. Nothing reads {gem_configs}; gem
|
|
316
421
|
# source indexing is not implemented.
|
|
317
422
|
#
|
|
@@ -325,6 +430,15 @@ module Woods
|
|
|
325
430
|
|
|
326
431
|
private
|
|
327
432
|
|
|
433
|
+
# @return [Integer, nil] +value+ when it is nil or a positive Integer
|
|
434
|
+
# @raise [ConfigurationError] otherwise
|
|
435
|
+
def validate_optional_positive_integer!(name, value)
|
|
436
|
+
return value if value.nil?
|
|
437
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
438
|
+
|
|
439
|
+
raise ConfigurationError, "#{name} must be a positive Integer or nil, got #{value.inspect}"
|
|
440
|
+
end
|
|
441
|
+
|
|
328
442
|
def validate_boolean!(name, value)
|
|
329
443
|
return if value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
330
444
|
|
|
@@ -408,11 +522,8 @@ module Woods
|
|
|
408
522
|
# @param output_dir [String] Override output directory
|
|
409
523
|
# @return [Hash] Extraction results
|
|
410
524
|
def extract!(output_dir: nil)
|
|
411
|
-
require_relative 'woods/extractor'
|
|
412
|
-
|
|
413
525
|
dir = output_dir || configuration.output_dir
|
|
414
|
-
|
|
415
|
-
extractor.extract_all
|
|
526
|
+
with_extraction_writer(dir, &:extract_all)
|
|
416
527
|
end
|
|
417
528
|
|
|
418
529
|
# Perform incremental extraction
|
|
@@ -420,10 +531,23 @@ module Woods
|
|
|
420
531
|
# @param changed_files [Array<String>] List of changed files
|
|
421
532
|
# @return [Array<String>] Re-extracted unit identifiers
|
|
422
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)
|
|
423
542
|
require_relative 'woods/extractor'
|
|
543
|
+
require_relative 'woods/rake_helpers'
|
|
424
544
|
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
427
551
|
end
|
|
428
552
|
end
|
|
429
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.
|
|
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
|
data/plugin/hooks/hooks.json
CHANGED
|
@@ -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
|
+
}
|