woods 2.0.0.beta2 → 2.0.0.beta4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +339 -1
- data/CONTRIBUTING.md +188 -12
- data/README.md +93 -174
- data/SECURITY.md +9 -6
- data/docs/AGENT_GUIDE.md +109 -8
- data/docs/AGENT_SETUP.md +98 -7
- data/docs/BACKEND_MATRIX.md +25 -0
- data/docs/CLIENT_HOOKS.md +111 -0
- data/docs/CONFIGURATION_REFERENCE.md +267 -16
- data/docs/CONSOLE_MCP_SETUP.md +80 -7
- data/docs/DOCKER_SETUP.md +22 -3
- data/docs/EVALUATION.md +464 -1
- data/docs/EXTRACTOR_REFERENCE.md +45 -6
- data/docs/FAQ.md +11 -12
- data/docs/GETTING_STARTED.md +17 -5
- data/docs/INCREMENTAL_EXTRACTION.md +147 -7
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +7 -2
- data/docs/MCP_SERVERS.md +276 -5
- data/docs/MCP_TOOL_COOKBOOK.md +37 -22
- data/docs/MCP_WORKTREE_SETUP.md +43 -83
- data/docs/NOTION_INTEGRATION.md +13 -0
- data/docs/OBSIDIAN_INTEGRATION.md +57 -9
- data/docs/PUBLISHED_INDEX.md +72 -0
- data/docs/README.md +7 -0
- data/docs/RETRIEVAL_GUIDE.md +273 -12
- data/docs/RUNTIME_TRACING.md +71 -0
- data/docs/SOURCE_FRESHNESS.md +143 -0
- data/docs/TROUBLESHOOTING.md +129 -18
- data/docs/UNBLOCKED_INTEGRATION.md +25 -0
- data/docs/UPGRADING_TO_2.md +48 -22
- data/docs/WATCH_DAEMON.md +277 -67
- data/exe/woods-agent-config +6 -0
- data/exe/woods-extract +5 -0
- data/exe/woods-hook-context +6 -0
- data/exe/woods-mcp-start +14 -9
- data/lib/generators/woods/pgvector_generator.rb +8 -2
- data/lib/generators/woods/templates/woods.rb.tt +1 -3
- data/lib/tasks/woods.rake +47 -397
- data/lib/woods/agent_configuration/applier.rb +135 -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 +72 -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 +18 -17
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/dispatch_pipeline.rb +7 -0
- data/lib/woods/console/embedded_executor.rb +32 -10
- 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/console/sql_noise_stripper.rb +9 -7
- data/lib/woods/console/sql_table_scanner.rb +47 -7
- data/lib/woods/console/sql_validator.rb +49 -9
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/coordination/pipeline_lock.rb +3 -2
- data/lib/woods/dependency_graph.rb +65 -13
- data/lib/woods/embedding/corpus.rb +94 -0
- data/lib/woods/embedding/indexer.rb +114 -60
- 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 +277 -149
- 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/declared_parent.rb +55 -0
- 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 +10 -13
- 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 +13 -9
- data/lib/woods/extractors/mailer_extractor.rb +26 -15
- 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 +26 -34
- 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 +13 -9
- 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 +48 -19
- 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 +22 -13
- data/lib/woods/mcp/bootstrapper.rb +79 -4
- data/lib/woods/mcp/config_resolver.rb +2 -1
- data/lib/woods/mcp/index_reader.rb +334 -162
- 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 +22 -9
- data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
- data/lib/woods/mcp/search_results.rb +74 -0
- data/lib/woods/mcp/server.rb +178 -63
- data/lib/woods/mcp/tool_contract.rb +3 -1
- data/lib/woods/mcp/tool_response_renderer.rb +41 -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/mcp/traversal_response.rb +22 -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 +13 -6
- data/lib/woods/payload_store.rb +27 -26
- data/lib/woods/published_index/typed_unit_reader.rb +40 -3
- data/lib/woods/published_index.rb +2 -2
- data/lib/woods/railtie.rb +3 -3
- data/lib/woods/railtie_support.rb +12 -12
- data/lib/woods/rake_helpers.rb +382 -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 +84 -0
- data/lib/woods/retrieval/lexical_index.rb +120 -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/file_store.rb +6 -1
- 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 +31 -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 +35 -10
- 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 +58 -9
- 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 +154 -32
- 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 +19 -0
- data/plugin/skills/woods-diagnose/SKILL.md +319 -1
- data/plugin/skills/woods-investigate/SKILL.md +145 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
- data/plugin/skills/woods-setup/SKILL.md +110 -6
- metadata +87 -5
|
@@ -18,11 +18,14 @@ module Woods
|
|
|
18
18
|
# is what keeps that bounded: skipping `.git`, `node_modules`, `tmp` and
|
|
19
19
|
# friends takes a Rails app from "everything" to "the source tree".
|
|
20
20
|
class PollingWatcher
|
|
21
|
+
# Optional daemon handshake; called once detection is established.
|
|
22
|
+
attr_writer :ready_callback
|
|
23
|
+
|
|
21
24
|
# @param root [String, Pathname] directory to watch
|
|
22
25
|
# @param ignored [Array<String>] directory names/prefixes to skip
|
|
23
26
|
# @param interval [Float] seconds between scans
|
|
24
27
|
# @param sleeper [#call] injected for specs, so they need not pass time
|
|
25
|
-
def initialize(root:, ignored: Watcher::DEFAULT_IGNORED_DIRECTORIES, interval:
|
|
28
|
+
def initialize(root:, ignored: Watcher::DEFAULT_IGNORED_DIRECTORIES, interval: Watcher::DEFAULT_POLL_INTERVAL,
|
|
26
29
|
sleeper: ->(seconds) { sleep(seconds) })
|
|
27
30
|
@root = root.to_s
|
|
28
31
|
@ignored = ignored
|
|
@@ -47,6 +50,7 @@ module Woods
|
|
|
47
50
|
|
|
48
51
|
@running = true
|
|
49
52
|
primed_now?
|
|
53
|
+
@ready_callback&.call
|
|
50
54
|
|
|
51
55
|
while @running
|
|
52
56
|
@sleeper.call(@interval)
|
data/lib/woods/watch/status.rb
CHANGED
|
@@ -31,9 +31,10 @@ module Woods
|
|
|
31
31
|
STATES = %i[running degraded stopped].freeze
|
|
32
32
|
|
|
33
33
|
# A status record older than this is not believed, however healthy it
|
|
34
|
-
# claims to be.
|
|
35
|
-
#
|
|
34
|
+
# claims to be. The daemon heartbeats every five minutes, including
|
|
35
|
+
# while idle, so this allows two missed heartbeats.
|
|
36
36
|
STALE_AFTER = 900 # 15 minutes
|
|
37
|
+
MAX_FUTURE_SKEW = 30 # seconds of clock difference tolerated across hosts
|
|
37
38
|
|
|
38
39
|
# @param output_dir [String, Pathname] index directory
|
|
39
40
|
# @param clock [#call] returns the ISO8601 stamp for a write
|
|
@@ -86,31 +87,34 @@ module Woods
|
|
|
86
87
|
|
|
87
88
|
# Is a daemon currently maintaining this index?
|
|
88
89
|
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
# `running` record with a pid some unrelated process now owns).
|
|
90
|
+
# Require a live state, a positive pid, and a recent timestamp. A local
|
|
91
|
+
# pid must still exist; foreign-host trust explicitly substitutes bounded
|
|
92
|
+
# heartbeat freshness for that uncheckable process evidence. A crashed
|
|
93
|
+
# foreign daemon can therefore remain believable until the record ages out.
|
|
94
94
|
#
|
|
95
95
|
# Callers use this to decide whether to do the work themselves — a
|
|
96
96
|
# session-start hook that would otherwise run `woods:incremental` can
|
|
97
97
|
# skip it when a daemon is already on the job.
|
|
98
98
|
#
|
|
99
99
|
# @param max_age [Numeric] seconds after which a record is disbelieved
|
|
100
|
+
# @param trust_foreign_host [Boolean] trust a fresh foreign-host record
|
|
101
|
+
# without a local pid check; defaults to WOODS_WATCH_TRUST_FOREIGN_HOST=1
|
|
100
102
|
# @return [Boolean]
|
|
101
|
-
def alive?(max_age: STALE_AFTER)
|
|
103
|
+
def alive?(max_age: STALE_AFTER, trust_foreign_host: ENV['WOODS_WATCH_TRUST_FOREIGN_HOST'] == '1')
|
|
102
104
|
record = read
|
|
103
105
|
return false unless %w[running degraded].include?(record['state'])
|
|
104
|
-
return false unless
|
|
105
|
-
return false unless
|
|
106
|
+
return false unless record['pid'].is_a?(Integer) && record['pid'].positive?
|
|
107
|
+
return false unless recent?(record['updated_at'], max_age)
|
|
108
|
+
return trust_foreign_host unless same_host?(record['host'])
|
|
106
109
|
|
|
107
|
-
|
|
110
|
+
process_alive?(record['pid'])
|
|
108
111
|
end
|
|
109
112
|
|
|
110
113
|
# The identity a pid is only meaningful within.
|
|
111
114
|
#
|
|
112
115
|
# In a container the hostname defaults to the container id, so this
|
|
113
|
-
# changes
|
|
116
|
+
# usually changes with the pid namespace. Custom or reused hostnames
|
|
117
|
+
# cannot establish namespace identity.
|
|
114
118
|
#
|
|
115
119
|
# @return [String]
|
|
116
120
|
def self.host_identity
|
|
@@ -128,8 +132,8 @@ module Woods
|
|
|
128
132
|
# deployment. A container pid like 47 almost always exists on the host, so
|
|
129
133
|
# a host hook would read `running` plus a live-looking pid plus a fresh
|
|
130
134
|
# timestamp and stand down while nothing was covering it. Comparing the
|
|
131
|
-
# recorded host
|
|
132
|
-
#
|
|
135
|
+
# recorded host keeps a cross-namespace reader from checking an unrelated
|
|
136
|
+
# local pid. Foreign records are rejected unless freshness trust is enabled.
|
|
133
137
|
#
|
|
134
138
|
# Records written before this field existed have no host; treat them as
|
|
135
139
|
# same-host so an in-place upgrade does not declare a live daemon dead.
|
|
@@ -160,7 +164,8 @@ module Woods
|
|
|
160
164
|
def recent?(iso8601, max_age)
|
|
161
165
|
return false if iso8601.nil?
|
|
162
166
|
|
|
163
|
-
Time.
|
|
167
|
+
age = Time.iso8601(@clock.call) - Time.iso8601(iso8601)
|
|
168
|
+
age.between?(-MAX_FUTURE_SKEW, max_age)
|
|
164
169
|
rescue ArgumentError, TypeError
|
|
165
170
|
false
|
|
166
171
|
end
|
|
@@ -50,14 +50,14 @@ module Woods
|
|
|
50
50
|
def each_file(root:, ignored:, visited: nil, &block)
|
|
51
51
|
base = root.to_s
|
|
52
52
|
prefix = "#{base}/"
|
|
53
|
-
visited
|
|
53
|
+
visited = (visited || Set.new).dup
|
|
54
54
|
visited << real_dir(base)
|
|
55
55
|
|
|
56
56
|
Find.find(base) do |path|
|
|
57
57
|
next if path == base
|
|
58
58
|
|
|
59
59
|
skip = skip?(path.delete_prefix(prefix), ignored)
|
|
60
|
-
Find.prune if visit_entry(path, skip, ignored, visited, &block) == :prune
|
|
60
|
+
Find.prune if visit_entry(path, skip, ignored, visited, root: base, &block) == :prune
|
|
61
61
|
end
|
|
62
62
|
rescue Errno::ENOENT
|
|
63
63
|
# The root vanished mid-walk (a worktree removed under us). Nothing to
|
|
@@ -68,7 +68,7 @@ module Woods
|
|
|
68
68
|
# Classify one entry and act on it.
|
|
69
69
|
#
|
|
70
70
|
# @return [Symbol, nil] `:prune` when the caller should not descend
|
|
71
|
-
def visit_entry(path, skip, ignored, visited, &block)
|
|
71
|
+
def visit_entry(path, skip, ignored, visited, root:, &block)
|
|
72
72
|
# `Find` stats with `lstat`, so it neither descends a symlinked
|
|
73
73
|
# directory nor reports one as a file — the entry would just vanish. A
|
|
74
74
|
# full extraction globs, and `Dir.glob` *does* follow them, so leaving
|
|
@@ -76,7 +76,7 @@ module Woods
|
|
|
76
76
|
# symlinked `app/models`, or a monorepo linking shared code in, simply
|
|
77
77
|
# never produced an event.
|
|
78
78
|
if symlinked_directory?(path)
|
|
79
|
-
descend_symlink(path, ignored, visited, &block) unless skip
|
|
79
|
+
descend_symlink(path, ignored, visited | directory_ancestors(path, root), &block) unless skip
|
|
80
80
|
nil
|
|
81
81
|
elsif File.directory?(path)
|
|
82
82
|
skip ? :prune : nil
|
|
@@ -86,19 +86,27 @@ module Woods
|
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
# Detect cycles along this logical traversal branch, not across siblings.
|
|
90
|
+
# Two aliases of one target are distinct extraction paths: suppressing
|
|
91
|
+
# one can leave only an ignored alias and hide the relevant app path.
|
|
92
|
+
# Find does not expose exit events for ordinary directories, so collect
|
|
93
|
+
# their ancestors at each symlink rather than retaining a global set.
|
|
94
|
+
def directory_ancestors(path, root)
|
|
95
|
+
ancestors = Set.new
|
|
96
|
+
directory = File.dirname(path)
|
|
97
|
+
loop do
|
|
98
|
+
ancestors << real_dir(directory)
|
|
99
|
+
break if directory == root || File.dirname(directory) == directory
|
|
100
|
+
|
|
101
|
+
directory = File.dirname(directory)
|
|
102
|
+
end
|
|
103
|
+
ancestors
|
|
104
|
+
end
|
|
105
|
+
|
|
96
106
|
def descend_symlink(path, ignored, visited, &block)
|
|
97
107
|
target = real_dir(path)
|
|
98
108
|
return if target.nil? || visited.include?(target)
|
|
99
109
|
|
|
100
|
-
visited << target
|
|
101
|
-
|
|
102
110
|
# Walk the *resolved* directory — `Find.find` lstats even the root it is
|
|
103
111
|
# handed, so pointing it at the link itself yields the link and stops —
|
|
104
112
|
# then rewrite each result back under the link. Callers compare these
|
data/lib/woods/watch/watcher.rb
CHANGED
|
@@ -27,6 +27,9 @@ module Woods
|
|
|
27
27
|
# Every backend implements the same two methods: `start(&on_change)`,
|
|
28
28
|
# which yields an array of absolute paths, and `stop`.
|
|
29
29
|
module Watcher
|
|
30
|
+
# Seconds of sleep between polling scans.
|
|
31
|
+
DEFAULT_POLL_INTERVAL = 1.0
|
|
32
|
+
|
|
30
33
|
# Directory names never worth watching. Scanning them is what makes a
|
|
31
34
|
# polling watcher expensive, and a change inside one is never extraction
|
|
32
35
|
# input.
|
|
@@ -52,7 +55,7 @@ module Woods
|
|
|
52
55
|
# @param force_polling [Boolean] skip the listen backend entirely
|
|
53
56
|
# @param logger [#info, #warn] where backend selection is reported
|
|
54
57
|
# @return [#start, #stop]
|
|
55
|
-
def build(root:, ignored: DEFAULT_IGNORED_DIRECTORIES, poll_interval:
|
|
58
|
+
def build(root:, ignored: DEFAULT_IGNORED_DIRECTORIES, poll_interval: DEFAULT_POLL_INTERVAL,
|
|
56
59
|
force_polling: false, logger: nil)
|
|
57
60
|
force_polling ||= containerized?
|
|
58
61
|
|
data/lib/woods.rb
CHANGED
|
@@ -135,7 +135,7 @@ module Woods
|
|
|
135
135
|
:concurrent_extraction, :precompute_flows, :extract_navigation_edges, :enable_snapshots,
|
|
136
136
|
:session_tracer_enabled, :session_tracer_allow_production,
|
|
137
137
|
:session_store, :session_id_proc, :session_exclude_paths,
|
|
138
|
-
:console_mcp_enabled, :console_mcp_path, :console_mcp_token,
|
|
138
|
+
:console_mcp_enabled, :console_mcp_http_enabled, :console_mcp_path, :console_mcp_token,
|
|
139
139
|
:console_mcp_allowed_origins,
|
|
140
140
|
:console_redacted_columns,
|
|
141
141
|
:console_redacted_key_values, :console_embedded_read_tools,
|
|
@@ -147,13 +147,15 @@ module Woods
|
|
|
147
147
|
:unblocked_api_token, :unblocked_collection_id, :unblocked_repo_url,
|
|
148
148
|
:cache_store, :cache_options,
|
|
149
149
|
:dump_retention_count, :component_paths
|
|
150
|
-
attr_reader :embedding_model, :max_context_tokens, :similarity_threshold, :extractors,
|
|
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.47",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "lost-in-the"
|
|
7
7
|
},
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
def valid_string:
|
|
2
|
+
type == "string" and length > 0 and utf8bytelength <= 4096 and (contains("\u0000") | not);
|
|
3
|
+
def invalid: error("Unsupported or malformed edit event; no refresh queued.");
|
|
4
|
+
. as $input |
|
|
5
|
+
if type != "object" then invalid
|
|
6
|
+
elif $client == "claude" then
|
|
7
|
+
if .hook_event_name == "PostToolUse" and (["Write", "Edit", "MultiEdit"] | index($input.tool_name)) then
|
|
8
|
+
{version:1,root:.cwd,events:[{path:.tool_input.file_path,operation:"update"}]}
|
|
9
|
+
else invalid end
|
|
10
|
+
elif $client == "opencode" and .version == 1 and .client == "opencode" then
|
|
11
|
+
{version:1,root:.root,events:.events}
|
|
12
|
+
else invalid end
|
|
13
|
+
| if (.root | valid_string) and (.root | startswith("/")) and (.events | type == "array" and length > 0 and length <= 1000)
|
|
14
|
+
and all(.events[]; type == "object" and (.path | valid_string) and (.operation as $operation | ["add","update","delete"] | index($operation)))
|
|
15
|
+
then .events |= (map({path,operation}) | unique_by(.path,.operation)) else invalid end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
# Standalone stdlib fallback for hosts without jq; never loads the Woods bundle.
|
|
6
|
+
module WoodsHookEvent
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def string?(value)
|
|
10
|
+
value.is_a?(String) && !value.empty? && value.bytesize <= 4096 && !value.include?("\0") && value.valid_encoding?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def normalize(client, input)
|
|
14
|
+
raise ArgumentError unless input.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
root, events = client_event(client, input)
|
|
17
|
+
validate_envelope(root, events)
|
|
18
|
+
|
|
19
|
+
normalized = events.map { |event| normalize_event(event) }.uniq
|
|
20
|
+
{ 'version' => 1, 'root' => root,
|
|
21
|
+
'events' => normalized.sort_by { |event| event.values_at('path', 'operation') } }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def validate_envelope(root, events)
|
|
25
|
+
raise ArgumentError unless string?(root) && root.start_with?('/')
|
|
26
|
+
raise ArgumentError unless events.is_a?(Array) && events.size.between?(1, 1000)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def client_event(client, input)
|
|
30
|
+
case client
|
|
31
|
+
when 'claude' then claude_event(input)
|
|
32
|
+
when 'opencode'
|
|
33
|
+
raise ArgumentError unless input['version'] == 1 && input['client'] == 'opencode'
|
|
34
|
+
|
|
35
|
+
[input['root'], input['events']]
|
|
36
|
+
else raise ArgumentError
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def claude_event(input)
|
|
41
|
+
raise ArgumentError unless input['hook_event_name'] == 'PostToolUse' &&
|
|
42
|
+
%w[Write Edit MultiEdit].include?(input['tool_name']) &&
|
|
43
|
+
input['tool_input'].is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
[input['cwd'], [{ 'path' => input.fetch('tool_input').fetch('file_path'), 'operation' => 'update' }]]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def normalize_event(event)
|
|
49
|
+
raise ArgumentError unless event.is_a?(Hash) && string?(event['path']) &&
|
|
50
|
+
%w[add update delete].include?(event['operation'])
|
|
51
|
+
|
|
52
|
+
{ 'path' => event['path'], 'operation' => event['operation'] }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if $PROGRAM_NAME == __FILE__
|
|
57
|
+
begin
|
|
58
|
+
puts JSON.generate(WoodsHookEvent.normalize(ARGV.fetch(0), JSON.parse($stdin.read(1_048_577))))
|
|
59
|
+
rescue JSON::ParserError, ArgumentError, KeyError, TypeError
|
|
60
|
+
warn '[Woods hooks] Unsupported or malformed edit event; no refresh queued.'
|
|
61
|
+
exit 1
|
|
62
|
+
end
|
|
63
|
+
end
|
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
|