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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +262 -1
- data/CONTRIBUTING.md +173 -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 +199 -14
- 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 +117 -1
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +7 -2
- 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 +55 -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/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 +232 -137
- 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/gem_mapper.rb +2 -0
- data/lib/woods/git_history.rb +116 -0
- data/lib/woods/graph_analyzer.rb +35 -6
- 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 +27 -26
- 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 +77 -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 +50 -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
|
@@ -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,
|
|
151
|
-
:
|
|
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
|
-
#
|
|
179
|
-
# 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.
|
|
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
|
-
|
|
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
|
-
|
|
511
|
-
|
|
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.
|
|
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
|
+
}
|