woods 2.0.0 → 2.1.0
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 +108 -0
- data/CONTRIBUTING.md +135 -10
- data/README.md +1 -1
- data/docs/AGENT_GUIDE.md +19 -0
- data/docs/AGENT_SETUP.md +22 -2
- data/docs/BACKEND_MATRIX.md +7 -0
- data/docs/CLIENT_HOOKS.md +6 -0
- data/docs/CONFIGURATION_REFERENCE.md +133 -18
- data/docs/CONSOLE_MCP_SETUP.md +141 -12
- data/docs/EMBEDDING_MODELS.md +16 -19
- data/docs/EXTRACTOR_REFERENCE.md +219 -21
- data/docs/FAQ.md +11 -25
- data/docs/GETTING_STARTED.md +7 -1
- data/docs/INCREMENTAL_EXTRACTION.md +261 -19
- data/docs/INDEX_LAYOUT.md +5 -0
- data/docs/INTERNALS.md +9 -0
- data/docs/MCP_HTTP_TRANSPORT.md +59 -2
- data/docs/MCP_SERVERS.md +87 -8
- data/docs/MCP_TOOL_COOKBOOK.md +13 -55
- data/docs/NOTION_INTEGRATION.md +7 -1
- data/docs/PUBLISHED_INDEX.md +6 -0
- data/docs/README.md +6 -1
- data/docs/RETRIEVAL_GUIDE.md +17 -0
- data/docs/SOURCE_FRESHNESS.md +157 -5
- data/docs/TOKEN_BENCHMARK.md +10 -18
- data/docs/TROUBLESHOOTING.md +70 -14
- data/docs/UNBLOCKED_INTEGRATION.md +60 -8
- data/docs/UPGRADING_TO_2.md +184 -9
- data/docs/WATCH_DAEMON.md +97 -14
- data/exe/woods-console-mcp +2 -2
- data/exe/woods-mcp-http +16 -9
- data/lib/generators/woods/templates/woods.rb.tt +2 -1
- data/lib/tasks/woods.rake +23 -7
- data/lib/tasks/woods_checks.rake +2 -2
- data/lib/woods/agent_configuration/cli.rb +1 -1
- data/lib/woods/agent_configuration/layout.rb +16 -2
- data/lib/woods/agent_configuration/plan.rb +13 -3
- data/lib/woods/agent_configuration/planner_validation.rb +4 -2
- data/lib/woods/agent_configuration/preflight.rb +5 -3
- data/lib/woods/builder.rb +17 -57
- data/lib/woods/cache/cache_middleware.rb +68 -41
- data/lib/woods/chunking/contributor_chunks.rb +119 -0
- data/lib/woods/chunking/semantic_chunker.rb +44 -21
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/connection_manager.rb +56 -3
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/embedded_executor.rb +431 -48
- data/lib/woods/console/model_validator.rb +8 -0
- data/lib/woods/console/rack_middleware.rb +68 -11
- data/lib/woods/console/redactor.rb +24 -10
- data/lib/woods/console/safe_context.rb +44 -7
- data/lib/woods/console/sql_noise_stripper.rb +41 -12
- data/lib/woods/console/sql_table_scanner.rb +45 -34
- data/lib/woods/console/sql_validator.rb +37 -2
- data/lib/woods/dependency_graph.rb +34 -10
- data/lib/woods/embedding/fake.rb +12 -0
- data/lib/woods/embedding/indexer.rb +195 -98
- data/lib/woods/embedding/input_budget.rb +67 -0
- data/lib/woods/embedding/openai.rb +70 -20
- data/lib/woods/embedding/provider.rb +37 -25
- data/lib/woods/embedding/text_preparer.rb +76 -32
- data/lib/woods/embedding/token_counter.rb +18 -81
- data/lib/woods/embedding/vector_configuration.rb +48 -0
- data/lib/woods/extraction_identities.rb +175 -0
- data/lib/woods/extractor.rb +304 -107
- data/lib/woods/extractors/action_cable_extractor.rb +8 -3
- data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
- data/lib/woods/extractors/class_declarations.rb +121 -0
- data/lib/woods/extractors/configuration_extractor.rb +11 -3
- data/lib/woods/extractors/declaration_ancestry.rb +92 -0
- data/lib/woods/extractors/event_extractor.rb +8 -0
- data/lib/woods/extractors/graphql_extractor.rb +134 -77
- data/lib/woods/extractors/job_extractor.rb +5 -1
- data/lib/woods/extractors/lib_extractor.rb +132 -15
- data/lib/woods/extractors/mailer_extractor.rb +3 -5
- data/lib/woods/extractors/manager_extractor.rb +7 -21
- data/lib/woods/extractors/migration_declaration.rb +87 -0
- data/lib/woods/extractors/migration_extractor.rb +5 -39
- data/lib/woods/extractors/phlex_extractor.rb +6 -2
- data/lib/woods/extractors/policy_extractor.rb +9 -5
- data/lib/woods/extractors/poro_extractor.rb +112 -53
- data/lib/woods/extractors/pundit_extractor.rb +11 -6
- data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
- data/lib/woods/extractors/serializer_extractor.rb +34 -22
- data/lib/woods/extractors/shared_utility_methods.rb +18 -1
- data/lib/woods/extractors/source_nesting.rb +142 -106
- data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
- data/lib/woods/extractors/state_machine_extractor.rb +46 -40
- data/lib/woods/extractors/view_component_extractor.rb +9 -7
- data/lib/woods/flow_assembler.rb +4 -1
- data/lib/woods/generation.rb +25 -0
- data/lib/woods/hooks/context_hint.rb +7 -2
- data/lib/woods/mcp/bearer_auth.rb +1 -1
- data/lib/woods/mcp/bootstrapper.rb +33 -7
- data/lib/woods/mcp/config_resolver.rb +26 -7
- data/lib/woods/mcp/index_reader.rb +125 -24
- data/lib/woods/mcp/index_reader_pinning.rb +16 -0
- data/lib/woods/mcp/origin_guard.rb +24 -77
- data/lib/woods/mcp/origin_policy.rb +124 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
- data/lib/woods/mcp/search_results.rb +7 -1
- data/lib/woods/mcp/server.rb +24 -4
- data/lib/woods/module_reconciliation.rb +151 -0
- data/lib/woods/path_dispatcher.rb +7 -2
- data/lib/woods/railtie_support.rb +8 -0
- data/lib/woods/rake_helpers.rb +43 -11
- data/lib/woods/release.rb +1 -1
- data/lib/woods/resilience/index_validator.rb +8 -3
- data/lib/woods/resilience/retryable_provider.rb +18 -1
- data/lib/woods/resolved_config.rb +68 -8
- data/lib/woods/retrieval/context_assembler.rb +3 -3
- data/lib/woods/retrieval/lexical_assembler.rb +3 -2
- data/lib/woods/retrieval/scope.rb +18 -2
- data/lib/woods/retrieval/source_evidence.rb +14 -2
- data/lib/woods/source_contributor_validation.rb +78 -0
- data/lib/woods/source_contributors.rb +116 -0
- data/lib/woods/source_inputs/handoff.rb +37 -0
- data/lib/woods/source_inputs/launcher.rb +53 -13
- data/lib/woods/source_inputs/manifest.rb +84 -3
- data/lib/woods/source_inputs/private_key.rb +44 -12
- data/lib/woods/source_inputs/scanner.rb +98 -27
- data/lib/woods/source_inputs/scopes.rb +1 -1
- data/lib/woods/source_inputs/session.rb +147 -15
- data/lib/woods/source_inputs/stable_reader.rb +127 -0
- data/lib/woods/source_inputs/status.rb +40 -8
- data/lib/woods/source_inputs/verifier.rb +28 -5
- data/lib/woods/source_path_encoding.rb +33 -0
- data/lib/woods/source_references/cache.rb +284 -0
- data/lib/woods/source_references/collector.rb +120 -0
- data/lib/woods/source_references/extraction.rb +185 -0
- data/lib/woods/source_references/inputs.rb +134 -0
- data/lib/woods/source_references/parser_adapter.rb +134 -0
- data/lib/woods/source_references/pass.rb +152 -0
- data/lib/woods/source_references/prism_adapter.rb +116 -0
- data/lib/woods/source_references/registry.rb +178 -0
- data/lib/woods/source_references/runtime_lookup.rb +127 -0
- data/lib/woods/source_references/value_class.rb +82 -0
- data/lib/woods/storage/metadata_store.rb +4 -1
- data/lib/woods/storage/qdrant.rb +2 -2
- data/lib/woods/unblocked/client.rb +12 -7
- data/lib/woods/unblocked/document_builder.rb +4 -1
- data/lib/woods/unblocked/exporter.rb +127 -37
- data/lib/woods/unblocked/sync_manifest.rb +137 -21
- data/lib/woods/unblocked/uri_migration.rb +105 -0
- data/lib/woods/util/host_guard.rb +3 -2
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/catch_up.rb +138 -0
- data/lib/woods/watch/claim_lease.rb +150 -0
- data/lib/woods/watch/cli.rb +26 -2
- data/lib/woods/watch/daemon.rb +80 -59
- data/lib/woods/watch/installation/options.rb +1 -1
- data/lib/woods/watch/installation/receipt.rb +6 -1
- data/lib/woods/watch/managed_child.rb +1 -1
- data/lib/woods/watch/supervisor.rb +1 -1
- data/lib/woods/watch/tree_scan.rb +14 -2
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.rb +3 -2
- data/plugin/hooks/woods-input-rules.sh +4 -0
- data/plugin/hooks/woods-refresh.sh +15 -7
- data/plugin/hooks/woods-session-start.sh +60 -3
- data/plugin/skills/woods-diagnose/SKILL.md +336 -2
- data/plugin/skills/woods-investigate/SKILL.md +11 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +86 -0
- data/plugin/skills/woods-setup/SKILL.md +56 -1
- metadata +34 -5
data/lib/woods/watch/daemon.rb
CHANGED
|
@@ -10,6 +10,8 @@ require_relative 'status'
|
|
|
10
10
|
require_relative 'tree_scan'
|
|
11
11
|
require_relative 'watcher'
|
|
12
12
|
require_relative 'boot_snapshot'
|
|
13
|
+
require_relative 'catch_up'
|
|
14
|
+
require_relative 'claim_lease'
|
|
13
15
|
require 'json'
|
|
14
16
|
require 'set'
|
|
15
17
|
require 'securerandom'
|
|
@@ -110,7 +112,7 @@ module Woods
|
|
|
110
112
|
# daemons that both pass {#another_daemon_alive?} before either has
|
|
111
113
|
# published a status record would otherwise both proceed. See
|
|
112
114
|
# {#claim_startup?}.
|
|
113
|
-
CLAIM_FILENAME =
|
|
115
|
+
CLAIM_FILENAME = ClaimLease::CLAIM
|
|
114
116
|
|
|
115
117
|
# A daemon cycle is milliseconds; a manual full extraction is seconds to
|
|
116
118
|
# minutes. This bounds how long a crashed writer can block the daemon.
|
|
@@ -423,7 +425,7 @@ module Woods
|
|
|
423
425
|
paths = paths.reject { |path| startup_covered?(path) } if startup
|
|
424
426
|
relative = ChangeSet.new(paths: paths, root: @root).relative_paths
|
|
425
427
|
action = @policy.classify_all(relative)
|
|
426
|
-
action = :reextract if action == :ignore &&
|
|
428
|
+
action = :reextract if action == :ignore && reconciliation_required?
|
|
427
429
|
return :restart if action == :reload && !@reloader.enabled?
|
|
428
430
|
|
|
429
431
|
action
|
|
@@ -448,6 +450,7 @@ module Woods
|
|
|
448
450
|
@startup_paths = Set.new
|
|
449
451
|
@live_paths = Set.new
|
|
450
452
|
@startup_full = false
|
|
453
|
+
@reconciliation_pending = false
|
|
451
454
|
@pending_mutex = Mutex.new
|
|
452
455
|
@stop_reason = nil
|
|
453
456
|
@drain_mutex = Mutex.new
|
|
@@ -492,7 +495,12 @@ module Woods
|
|
|
492
495
|
end
|
|
493
496
|
|
|
494
497
|
def carry_forward(change_set)
|
|
495
|
-
@pending_mutex.synchronize
|
|
498
|
+
@pending_mutex.synchronize do
|
|
499
|
+
@pending.merge(change_set.absolute_paths)
|
|
500
|
+
# Deletion-only startup intentionally names no paths: its bounded
|
|
501
|
+
# sweep must not treat nominal framework paths as deleted sources.
|
|
502
|
+
@reconciliation_pending = true if change_set.empty?
|
|
503
|
+
end
|
|
496
504
|
end
|
|
497
505
|
|
|
498
506
|
# Run cycles until the pending set is empty. One call per watcher batch;
|
|
@@ -542,7 +550,11 @@ module Woods
|
|
|
542
550
|
end
|
|
543
551
|
|
|
544
552
|
def pending_empty?
|
|
545
|
-
@pending_mutex.synchronize { @pending.empty? }
|
|
553
|
+
@pending_mutex.synchronize { @pending.empty? && !@reconciliation_pending }
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def reconciliation_required?
|
|
557
|
+
@pending_mutex.synchronize { @startup_full || @reconciliation_pending }
|
|
546
558
|
end
|
|
547
559
|
|
|
548
560
|
# Only a drained startup boundary (or a later recovery) can establish
|
|
@@ -613,24 +625,22 @@ module Woods
|
|
|
613
625
|
|
|
614
626
|
# Reconcile changes that predate this daemon.
|
|
615
627
|
#
|
|
616
|
-
#
|
|
617
|
-
#
|
|
618
|
-
# Anything modified since is uncovered, whoever made the change and
|
|
619
|
-
# whether or not a daemon was watching at the time. With no generation
|
|
620
|
-
# file at all there is no index, and every file is uncovered — which the
|
|
621
|
-
# storm threshold correctly turns into one full extraction.
|
|
628
|
+
# Capture precedes publication. CatchUp keeps edits in that interval
|
|
629
|
+
# visible, including those after the final source-verification scan.
|
|
622
630
|
#
|
|
623
631
|
# @return [void]
|
|
624
632
|
def catch_up
|
|
625
633
|
return unless @catch_up
|
|
626
634
|
|
|
627
635
|
carried = restore_pending
|
|
628
|
-
|
|
636
|
+
scan = catch_up_scan
|
|
637
|
+
paths = (scan.paths + carried + vanished_restart_paths).uniq
|
|
629
638
|
boot_changes = @boot_snapshot ? @boot_snapshot.changed_paths : []
|
|
630
639
|
enqueue(boot_changes) unless boot_changes.empty?
|
|
631
|
-
prepare_startup_reconciliation(paths)
|
|
640
|
+
prepare_startup_reconciliation(paths, rebuild: scan.rebuild?)
|
|
632
641
|
paths |= boot_changes
|
|
633
642
|
if paths.empty?
|
|
643
|
+
return extract(ChangeSet.new(paths: [], root: @root)) if @startup_full
|
|
634
644
|
return reconcile_deletions if stale_deletions?
|
|
635
645
|
|
|
636
646
|
return @logger.info('[Woods] watch: index is current at startup')
|
|
@@ -641,7 +651,8 @@ module Woods
|
|
|
641
651
|
drain
|
|
642
652
|
end
|
|
643
653
|
|
|
644
|
-
def prepare_startup_reconciliation(paths)
|
|
654
|
+
def prepare_startup_reconciliation(paths, rebuild: false)
|
|
655
|
+
@startup_full = rebuild
|
|
645
656
|
return unless @boot_snapshot
|
|
646
657
|
|
|
647
658
|
restart_paths = paths.select do |path|
|
|
@@ -650,7 +661,7 @@ module Woods
|
|
|
650
661
|
end
|
|
651
662
|
@pending_mutex.synchronize do
|
|
652
663
|
@startup_paths.merge(restart_paths)
|
|
653
|
-
@startup_full
|
|
664
|
+
@startup_full ||= restart_paths.any?
|
|
654
665
|
end
|
|
655
666
|
end
|
|
656
667
|
|
|
@@ -721,38 +732,11 @@ module Woods
|
|
|
721
732
|
end
|
|
722
733
|
|
|
723
734
|
def uncovered_paths
|
|
724
|
-
|
|
725
|
-
TreeScan.files(root: @root, ignored: ignored_directories)
|
|
726
|
-
.select { |path| uncovered?(path, watermark) }
|
|
735
|
+
catch_up_scan.paths
|
|
727
736
|
end
|
|
728
737
|
|
|
729
|
-
def
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
File.mtime(path).to_f > watermark
|
|
733
|
-
rescue SystemCallError
|
|
734
|
-
false
|
|
735
|
-
end
|
|
736
|
-
|
|
737
|
-
# When this index was last known good, or nil when there is no usable
|
|
738
|
-
# index — which the storm threshold turns into one full extraction.
|
|
739
|
-
#
|
|
740
|
-
# A dangling payload pointer counts as "no index" (INF-10). The marker
|
|
741
|
-
# can outlive the directory it names (a partial restore from a CI
|
|
742
|
-
# artifact, an external cleanup targeting the large directories), and
|
|
743
|
-
# {Generation#payload_dir} deliberately degrades to the index root for
|
|
744
|
-
# *readers* — so a gutted index would otherwise read "current at
|
|
745
|
-
# startup", publish `running`, and have every caller stand down over a
|
|
746
|
-
# directory holding no index at all. "Alive means covered" is the
|
|
747
|
-
# daemon's contract; here it would have been false.
|
|
748
|
-
#
|
|
749
|
-
# @return [Float, nil]
|
|
750
|
-
def index_watermark
|
|
751
|
-
return nil if dangling_payload_pointer?
|
|
752
|
-
|
|
753
|
-
File.mtime(@generation.path).to_f
|
|
754
|
-
rescue SystemCallError
|
|
755
|
-
nil
|
|
738
|
+
def catch_up_scan
|
|
739
|
+
CatchUp.new(root: @root, output_dir: @output_dir, ignored: ignored_directories)
|
|
756
740
|
end
|
|
757
741
|
|
|
758
742
|
# @return [Boolean] true when the marker names a payload directory that
|
|
@@ -1018,6 +1002,7 @@ module Woods
|
|
|
1018
1002
|
else
|
|
1019
1003
|
extractor.extract_changed(change_set.absolute_paths)
|
|
1020
1004
|
end
|
|
1005
|
+
extractor.raise_on_publication_failure! if extractor.respond_to?(:raise_on_publication_failure!)
|
|
1021
1006
|
|
|
1022
1007
|
action = full ? :full : :incremental
|
|
1023
1008
|
return unpublished(action, change_set, started) if wrote_without_publishing?(touched, before)
|
|
@@ -1037,7 +1022,7 @@ module Woods
|
|
|
1037
1022
|
|
|
1038
1023
|
def full_extraction?(change_set)
|
|
1039
1024
|
if @startup_full
|
|
1040
|
-
@logger.info('[Woods] watch:
|
|
1025
|
+
@logger.info('[Woods] watch: startup coverage requires full extraction')
|
|
1041
1026
|
return true
|
|
1042
1027
|
end
|
|
1043
1028
|
|
|
@@ -1050,6 +1035,7 @@ module Woods
|
|
|
1050
1035
|
|
|
1051
1036
|
def finish_extraction(action, change_set, touched, started)
|
|
1052
1037
|
result = publish(action, change_set, touched, started)
|
|
1038
|
+
@pending_mutex.synchronize { @reconciliation_pending = false }
|
|
1053
1039
|
if action == :full
|
|
1054
1040
|
@startup_full = false
|
|
1055
1041
|
@pending_mutex.synchronize { @startup_paths.clear }
|
|
@@ -1071,8 +1057,10 @@ module Woods
|
|
|
1071
1057
|
# previous index while the daemon says `running`. Not raising was right;
|
|
1072
1058
|
# not noticing was not.
|
|
1073
1059
|
#
|
|
1074
|
-
#
|
|
1075
|
-
# units
|
|
1060
|
+
# The extractor's explicit publication-error check above also covers
|
|
1061
|
+
# payload changes with no touched units, such as withdrawing flows. Keep
|
|
1062
|
+
# this fallback for injected extractors without that reporting method.
|
|
1063
|
+
# A genuine no-op deliberately does not bump.
|
|
1076
1064
|
def wrote_without_publishing?(touched, before)
|
|
1077
1065
|
return false if touched != :all && Array(touched).empty?
|
|
1078
1066
|
|
|
@@ -1188,7 +1176,31 @@ module Woods
|
|
|
1188
1176
|
def claim_startup?
|
|
1189
1177
|
return true if ENV['WOODS_IGNORE_WATCH'] == '1' && !@conservative_claims
|
|
1190
1178
|
|
|
1179
|
+
prepare_claim_lease if @conservative_claims
|
|
1180
|
+
acquire_startup_claim
|
|
1181
|
+
rescue ClaimLease::Unavailable, SystemCallError => e
|
|
1182
|
+
raise unless @conservative_claims
|
|
1183
|
+
|
|
1184
|
+
@logger.warn("[Woods] watch: managed claim unavailable: #{e.message}")
|
|
1185
|
+
false
|
|
1186
|
+
ensure
|
|
1187
|
+
close_unclaimed_lease
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
def close_unclaimed_lease
|
|
1191
|
+
return if @claimed
|
|
1192
|
+
|
|
1193
|
+
@claim_lease&.close
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
def prepare_claim_lease
|
|
1197
|
+
FileUtils.mkdir_p(@output_dir)
|
|
1198
|
+
@claim_lease = ClaimLease.new(@output_dir)
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
def acquire_startup_claim
|
|
1191
1202
|
with_claim_lock do
|
|
1203
|
+
@claim_lease&.acquire
|
|
1192
1204
|
3.times do
|
|
1193
1205
|
return true if create_claim
|
|
1194
1206
|
return false unless reclaim_if_stale
|
|
@@ -1203,11 +1215,13 @@ module Woods
|
|
|
1203
1215
|
# pauses, B deletes S and publishes its live claim, A resumes and
|
|
1204
1216
|
# unlinks B's claim, and both return as owners. An `flock` on a
|
|
1205
1217
|
# sidecar file makes the whole loop one critical section; the kernel
|
|
1206
|
-
# releases it if the holder dies.
|
|
1207
|
-
#
|
|
1208
|
-
def with_claim_lock
|
|
1218
|
+
# releases it if the holder dies. Managed startup refuses unavailable
|
|
1219
|
+
# locks; raw startup retains its earlier unserialized fallback.
|
|
1220
|
+
def with_claim_lock(&block)
|
|
1221
|
+
return @claim_lease.coordinate(&block) if @claim_lease
|
|
1222
|
+
|
|
1209
1223
|
lock = open_claim_lock
|
|
1210
|
-
|
|
1224
|
+
block.call
|
|
1211
1225
|
ensure
|
|
1212
1226
|
lock&.close
|
|
1213
1227
|
end
|
|
@@ -1243,7 +1257,7 @@ module Woods
|
|
|
1243
1257
|
# race this method exists to close.
|
|
1244
1258
|
def create_claim
|
|
1245
1259
|
FileUtils.mkdir_p(@output_dir)
|
|
1246
|
-
content = JSON.generate(pid: Process.pid, host: Status.host_identity)
|
|
1260
|
+
content = JSON.generate(pid: Process.pid, host: Status.host_identity, **(@claim_lease&.fields || {}))
|
|
1247
1261
|
tmp_path = "#{claim_path}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
|
|
1248
1262
|
File.write(tmp_path, content)
|
|
1249
1263
|
begin
|
|
@@ -1303,7 +1317,7 @@ module Woods
|
|
|
1303
1317
|
# @return [String, nil] the claim file's current bytes, or nil if it
|
|
1304
1318
|
# doesn't exist
|
|
1305
1319
|
def claim_bytes
|
|
1306
|
-
File.read(claim_path)
|
|
1320
|
+
@claim_lease ? @claim_lease.snapshot : File.read(claim_path)
|
|
1307
1321
|
rescue Errno::ENOENT
|
|
1308
1322
|
nil
|
|
1309
1323
|
end
|
|
@@ -1313,12 +1327,12 @@ module Woods
|
|
|
1313
1327
|
# or already-vanished claim counts as stale too, since it cannot be a
|
|
1314
1328
|
# live daemon's claim
|
|
1315
1329
|
def stale_claim?
|
|
1316
|
-
record = JSON.parse(
|
|
1330
|
+
record = JSON.parse(claim_bytes)
|
|
1317
1331
|
return false if @conservative_claims && !verifiable_managed_claim?(record)
|
|
1318
1332
|
return true unless same_claim_host?(record['host'])
|
|
1319
1333
|
|
|
1320
1334
|
!claim_pid_alive?(record['pid'])
|
|
1321
|
-
rescue JSON::ParserError, SystemCallError
|
|
1335
|
+
rescue JSON::ParserError, SystemCallError, TypeError
|
|
1322
1336
|
!@conservative_claims
|
|
1323
1337
|
end
|
|
1324
1338
|
|
|
@@ -1374,18 +1388,25 @@ module Woods
|
|
|
1374
1388
|
def release_claim
|
|
1375
1389
|
return unless @claimed
|
|
1376
1390
|
|
|
1377
|
-
|
|
1391
|
+
if @claim_lease
|
|
1392
|
+
@claim_lease.coordinate { FileUtils.rm_f(claim_path) if own_claim? }
|
|
1393
|
+
elsif own_claim?
|
|
1394
|
+
FileUtils.rm_f(claim_path)
|
|
1395
|
+
end
|
|
1396
|
+
ensure
|
|
1397
|
+
@claim_lease&.close
|
|
1378
1398
|
@claimed = false
|
|
1379
1399
|
end
|
|
1380
1400
|
|
|
1381
1401
|
# @return [Boolean] whether the claim on disk still records this process
|
|
1382
1402
|
def own_claim?
|
|
1383
|
-
record = JSON.parse(
|
|
1403
|
+
record = JSON.parse(claim_bytes)
|
|
1404
|
+
return record.is_a?(Hash) && record['token'] == @claim_lease.token if @claim_lease
|
|
1384
1405
|
return true unless record.is_a?(Hash)
|
|
1385
1406
|
|
|
1386
1407
|
record['pid'] == Process.pid && same_claim_host?(record['host'])
|
|
1387
|
-
rescue JSON::ParserError, SystemCallError
|
|
1388
|
-
|
|
1408
|
+
rescue JSON::ParserError, SystemCallError, TypeError, ClaimLease::Unavailable
|
|
1409
|
+
!@claim_lease
|
|
1389
1410
|
end
|
|
1390
1411
|
|
|
1391
1412
|
def claim_path
|
|
@@ -92,7 +92,7 @@ module Woods
|
|
|
92
92
|
|
|
93
93
|
def validate_idle!
|
|
94
94
|
value = @environment['WOODS_WATCH_IDLE_TIMEOUT']
|
|
95
|
-
return if mode == 'external' || value.
|
|
95
|
+
return if mode == 'external' || value.to_s.strip.empty?
|
|
96
96
|
|
|
97
97
|
raise Conflict, 'Unset WOODS_WATCH_IDLE_TIMEOUT; managed maintenance needs a resident child'
|
|
98
98
|
end
|
|
@@ -26,7 +26,12 @@ module Woods
|
|
|
26
26
|
# @param expected [Hash] prior content fingerprint and mode
|
|
27
27
|
# @return [void]
|
|
28
28
|
def self.verify_file!(document, expected)
|
|
29
|
-
|
|
29
|
+
actual = document.fingerprint
|
|
30
|
+
# Git records the owner's executable bit, not the checkout's umask.
|
|
31
|
+
# Plan snapshots separately retain exact modes for preview/apply drift.
|
|
32
|
+
same_content = actual.slice('sha256', 'exists') == expected.slice('sha256', 'exists')
|
|
33
|
+
same_executable = (actual.fetch('mode') & 0o100) == (expected.fetch('mode') & 0o100)
|
|
34
|
+
return if same_content && same_executable
|
|
30
35
|
|
|
31
36
|
raise Conflict, "Owned watcher file was edited or removed: #{document.path}"
|
|
32
37
|
end
|
|
@@ -26,7 +26,7 @@ module Woods
|
|
|
26
26
|
descriptor, launcher, attempt = values
|
|
27
27
|
validate_environment!(descriptor, [launcher, attempt])
|
|
28
28
|
|
|
29
|
-
unless env['WOODS_WATCH_IDLE_TIMEOUT'].to_s.empty?
|
|
29
|
+
unless env['WOODS_WATCH_IDLE_TIMEOUT'].to_s.strip.empty?
|
|
30
30
|
raise ArgumentError, 'WOODS_WATCH_IDLE_TIMEOUT must be unset for managed watching'
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -25,7 +25,7 @@ module Woods
|
|
|
25
25
|
def initialize(command:, root:, env: ENV.to_h, logger: $stderr, boot_timeout: 300,
|
|
26
26
|
shutdown_timeout: 10, retry_delays: RETRY_DELAYS)
|
|
27
27
|
raise ArgumentError, 'WOODS_WATCH_IDLE_TIMEOUT must be unset for managed watching' unless
|
|
28
|
-
env['WOODS_WATCH_IDLE_TIMEOUT'].to_s.empty?
|
|
28
|
+
env['WOODS_WATCH_IDLE_TIMEOUT'].to_s.strip.empty?
|
|
29
29
|
|
|
30
30
|
@command = command
|
|
31
31
|
@root = File.realpath(root)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'find'
|
|
4
4
|
require 'set'
|
|
5
|
+
require 'woods/source_path_encoding'
|
|
5
6
|
|
|
6
7
|
module Woods
|
|
7
8
|
module Watch
|
|
@@ -48,12 +49,15 @@ module Woods
|
|
|
48
49
|
# @yieldparam path [String] absolute path to a regular file
|
|
49
50
|
# @return [void]
|
|
50
51
|
def each_file(root:, ignored:, visited: nil, &block)
|
|
51
|
-
base = root
|
|
52
|
+
base = SourcePathEncoding.utf8(root)
|
|
53
|
+
return unless base
|
|
54
|
+
|
|
52
55
|
prefix = "#{base}/"
|
|
53
56
|
visited = (visited || Set.new).dup
|
|
54
57
|
visited << real_dir(base)
|
|
55
58
|
|
|
56
59
|
Find.find(base) do |path|
|
|
60
|
+
path = decoded_entry(path)
|
|
57
61
|
next if path == base
|
|
58
62
|
|
|
59
63
|
skip = skip?(path.delete_prefix(prefix), ignored)
|
|
@@ -65,6 +69,11 @@ module Woods
|
|
|
65
69
|
nil
|
|
66
70
|
end
|
|
67
71
|
|
|
72
|
+
# Prune before any string matching or descent into an undecodable entry.
|
|
73
|
+
def decoded_entry(path)
|
|
74
|
+
SourcePathEncoding.utf8(path) || Find.prune
|
|
75
|
+
end
|
|
76
|
+
|
|
68
77
|
# Classify one entry and act on it.
|
|
69
78
|
#
|
|
70
79
|
# @return [Symbol, nil] `:prune` when the caller should not descend
|
|
@@ -126,7 +135,7 @@ module Woods
|
|
|
126
135
|
|
|
127
136
|
# @return [String, nil] resolved path, or nil for a broken/looping link
|
|
128
137
|
def real_dir(path)
|
|
129
|
-
File.realpath(path)
|
|
138
|
+
SourcePathEncoding.utf8(File.realpath(path))
|
|
130
139
|
rescue SystemCallError
|
|
131
140
|
nil
|
|
132
141
|
end
|
|
@@ -147,6 +156,9 @@ module Woods
|
|
|
147
156
|
# @param relative [String] path relative to the watched root
|
|
148
157
|
# @return [Boolean] whether any segment is a dotfile Woods does not read
|
|
149
158
|
def hidden?(relative)
|
|
159
|
+
relative = SourcePathEncoding.utf8(relative)
|
|
160
|
+
return true unless relative
|
|
161
|
+
|
|
150
162
|
relative.split(File::SEPARATOR).any? { |segment| hidden_segment?(segment) }
|
|
151
163
|
end
|
|
152
164
|
|
|
@@ -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.71",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "lost-in-the"
|
|
7
7
|
},
|
|
@@ -55,8 +55,9 @@ end
|
|
|
55
55
|
|
|
56
56
|
if $PROGRAM_NAME == __FILE__
|
|
57
57
|
begin
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
input = $stdin.binmode.read(1_048_577).force_encoding(Encoding::UTF_8)
|
|
59
|
+
puts JSON.generate(WoodsHookEvent.normalize(ARGV.fetch(0), JSON.parse(input)))
|
|
60
|
+
rescue JSON::ParserError, EncodingError, ArgumentError, KeyError, TypeError
|
|
60
61
|
warn '[Woods hooks] Unsupported or malformed edit event; no refresh queued.'
|
|
61
62
|
exit 1
|
|
62
63
|
end
|
|
@@ -111,6 +111,10 @@ woods_input_action() {
|
|
|
111
111
|
case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
|
|
112
112
|
return
|
|
113
113
|
fi
|
|
114
|
+
if { { [[ "$path" != */tasks/* ]] && [[ "$path" != */generators/* ]] && { [[ "$path" == *.rb ]]; } && { [[ "$path" == lib/* ]]; }; }; }; then
|
|
115
|
+
case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
|
|
116
|
+
return
|
|
117
|
+
fi
|
|
114
118
|
if { { { [[ "$path" == *.rake ]]; } && { [[ "$path" == lib/tasks/* ]]; }; }; }; then
|
|
115
119
|
case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
|
|
116
120
|
return
|
|
@@ -21,18 +21,26 @@ if command -v jq >/dev/null 2>&1; then
|
|
|
21
21
|
printf '[Woods hooks] Unsupported or malformed edit event; no refresh queued.\n' >&2; exit 0;
|
|
22
22
|
}
|
|
23
23
|
elif command -v ruby >/dev/null 2>&1; then
|
|
24
|
-
payload="$(ruby "$hook_dir/adapters/normalize.rb" "$client" <"$input_file")" || exit 0
|
|
24
|
+
payload="$(ruby -EUTF-8:UTF-8 "$hook_dir/adapters/normalize.rb" "$client" <"$input_file")" || exit 0
|
|
25
25
|
else
|
|
26
26
|
printf '[Woods hooks] jq or Ruby is required; no refresh queued.\n' >&2
|
|
27
27
|
exit 0
|
|
28
28
|
fi
|
|
29
29
|
rm -f "$input_file"
|
|
30
30
|
trap - EXIT TERM INT
|
|
31
|
+
# JSON and client paths are UTF-8 regardless of the host process locale.
|
|
32
|
+
# Keep malformed queue/input diagnostics bounded and free of event contents.
|
|
33
|
+
ruby_json() {
|
|
34
|
+
ruby -EUTF-8:UTF-8 "$@" 2>/dev/null || {
|
|
35
|
+
printf '[Woods hooks] Invalid UTF-8 or JSON data; refresh not confirmed.\n' >&2
|
|
36
|
+
return 1
|
|
37
|
+
}
|
|
38
|
+
}
|
|
31
39
|
field() {
|
|
32
40
|
if command -v jq >/dev/null 2>&1; then
|
|
33
41
|
printf '%s' "$payload" | jq -ej '.root'
|
|
34
42
|
else
|
|
35
|
-
printf '%s' "$payload" |
|
|
43
|
+
printf '%s' "$payload" | ruby_json -rjson -e 'print JSON.parse($stdin.read).fetch("root")'
|
|
36
44
|
fi
|
|
37
45
|
}
|
|
38
46
|
cwd="$(field; printf .)"
|
|
@@ -50,7 +58,7 @@ event_fields() {
|
|
|
50
58
|
if command -v jq >/dev/null 2>&1; then
|
|
51
59
|
printf '%s' "$payload" | jq -j '.events[] | .operation,"\u0000",.path,"\u0000"'
|
|
52
60
|
else
|
|
53
|
-
printf '%s' "$payload" |
|
|
61
|
+
printf '%s' "$payload" | ruby_json -rjson -e 'JSON.parse($stdin.read).fetch("events").each { |e| print e.fetch("operation"), "\0", e.fetch("path"), "\0" }'
|
|
54
62
|
fi
|
|
55
63
|
}
|
|
56
64
|
relevant=0
|
|
@@ -83,7 +91,7 @@ enqueue() {
|
|
|
83
91
|
if command -v jq >/dev/null 2>&1; then
|
|
84
92
|
jq -nc --arg path "$1" '{path:$path,operation:"update"}' >"$event.tmp" || return 1
|
|
85
93
|
else
|
|
86
|
-
|
|
94
|
+
ruby_json -rjson -e 'puts JSON.generate(path: ARGV[0], operation: "update")' -- "$1" >"$event.tmp" || return 1
|
|
87
95
|
fi
|
|
88
96
|
mv "$event.tmp" "$event.json"
|
|
89
97
|
}
|
|
@@ -92,7 +100,7 @@ event="$queue/$$.$RANDOM.$RANDOM"
|
|
|
92
100
|
if command -v jq >/dev/null 2>&1; then
|
|
93
101
|
printf '%s' "$payload" | jq -c --arg root "$cwd" '.events | map(.path |= (if startswith($root + "/") then ltrimstr($root + "/") else . end))' >"$event.tmp" || exit 0
|
|
94
102
|
else
|
|
95
|
-
printf '%s' "$payload" |
|
|
103
|
+
printf '%s' "$payload" | ruby_json -rjson -e 'v = JSON.parse($stdin.read); puts JSON.generate(v.fetch("events").map { |e| e.merge("path" => e.fetch("path").delete_prefix(ARGV.fetch(0) + "/")) })' -- "$cwd" >"$event.tmp" || exit 0
|
|
96
104
|
fi
|
|
97
105
|
mv "$event.tmp" "$event.json" || exit 0
|
|
98
106
|
|
|
@@ -144,7 +152,7 @@ encode_batch() {
|
|
|
144
152
|
if command -v jq >/dev/null 2>&1; then
|
|
145
153
|
jq -sc --arg output "$configured_output" '{version:1,output:$output,events:(map(if type == "array" then . else [.] end) | add)} | @base64' "${batch[@]}" | tr -d '"\n'
|
|
146
154
|
else
|
|
147
|
-
|
|
155
|
+
ruby_json -rjson -rbase64 -e '
|
|
148
156
|
output = ARGV.shift
|
|
149
157
|
print Base64.strict_encode64(JSON.generate(version: 1, output: output,
|
|
150
158
|
events: ARGV.flat_map { |path| value = JSON.parse(File.read(path)); value.is_a?(Array) ? value : [value] }))
|
|
@@ -237,7 +245,7 @@ while :; do
|
|
|
237
245
|
if command -v jq >/dev/null 2>&1; then
|
|
238
246
|
event_count="$(jq 'if type == "array" then length else 1 end' "$candidate")" || break
|
|
239
247
|
else
|
|
240
|
-
event_count="$(
|
|
248
|
+
event_count="$(ruby_json -rjson -e 'v = JSON.parse(File.read(ARGV[0])); puts(v.is_a?(Array) ? v.size : 1)' "$candidate")" || break
|
|
241
249
|
fi
|
|
242
250
|
[ "$((batch_events + event_count))" -le 1000 ] || break
|
|
243
251
|
[ "$((batch_bytes + event_bytes))" -le 49152 ] || break
|
|
@@ -11,9 +11,59 @@ field() {
|
|
|
11
11
|
if command -v jq >/dev/null 2>&1; then
|
|
12
12
|
printf '%s' "$1" | jq -r --arg name "$2" '.[$name] // empty' 2>/dev/null
|
|
13
13
|
elif command -v ruby >/dev/null 2>&1; then
|
|
14
|
-
printf '%s' "$1" | ruby -rjson -e 'print JSON.parse($stdin.read).fetch(ARGV[0], "")' -- "$2" 2>/dev/null
|
|
14
|
+
printf '%s' "$1" | ruby -EUTF-8:UTF-8 -rjson -e 'print JSON.parse($stdin.read).fetch(ARGV[0], "")' -- "$2" 2>/dev/null
|
|
15
15
|
fi
|
|
16
16
|
}
|
|
17
|
+
unavailable_summary() {
|
|
18
|
+
if command -v jq >/dev/null 2>&1; then
|
|
19
|
+
printf '%s' "$1" | jq -r '.unavailable | select(type == "object") |
|
|
20
|
+
select(.reason == "source_manifest_too_large" and (.size_bytes | type) == "number" and
|
|
21
|
+
(.limit_bytes | type) == "number") |
|
|
22
|
+
select(.size_bytes > .limit_bytes and .limit_bytes > 0) |
|
|
23
|
+
"source_manifest_too_large; \(.size_bytes) bytes; limit \(.limit_bytes)"' 2>/dev/null
|
|
24
|
+
elif command -v ruby >/dev/null 2>&1; then
|
|
25
|
+
printf '%s' "$1" | ruby -EUTF-8:UTF-8 -rjson -e '
|
|
26
|
+
value = JSON.parse($stdin.read)["unavailable"]
|
|
27
|
+
if value.is_a?(Hash) && value["reason"] == "source_manifest_too_large"
|
|
28
|
+
size, limit = value.values_at("size_bytes", "limit_bytes")
|
|
29
|
+
if [size, limit].all?(Integer) && size > limit && limit > 0
|
|
30
|
+
print "source_manifest_too_large; #{size} bytes; limit #{limit}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
' 2>/dev/null
|
|
34
|
+
fi
|
|
35
|
+
}
|
|
36
|
+
source_advice() {
|
|
37
|
+
if command -v jq >/dev/null 2>&1; then
|
|
38
|
+
printf '%s' "$1" | jq -r 'if (.recommendations | type) == "array" then
|
|
39
|
+
.recommendations[] | select(. == "deep_check" or . == "fresh_capture" or . == "inspect_source_scan")
|
|
40
|
+
else empty end' 2>/dev/null
|
|
41
|
+
elif command -v ruby >/dev/null 2>&1; then
|
|
42
|
+
printf '%s' "$1" | ruby -EUTF-8:UTF-8 -rjson -e '
|
|
43
|
+
values = JSON.parse($stdin.read)["recommendations"]
|
|
44
|
+
allowed = %w[deep_check fresh_capture inspect_source_scan]
|
|
45
|
+
puts values.select { |value| allowed.include?(value) } if values.is_a?(Array)
|
|
46
|
+
' 2>/dev/null
|
|
47
|
+
fi
|
|
48
|
+
}
|
|
49
|
+
unknown_message() {
|
|
50
|
+
local advice action
|
|
51
|
+
advice="$(source_advice "$1")"
|
|
52
|
+
if [ -z "$advice" ]; then
|
|
53
|
+
echo 'Woods source freshness is unknown: this generation lacks complete verified source evidence. Inspect woods_status; use woods-extract full for a fresh capture.'
|
|
54
|
+
return
|
|
55
|
+
fi
|
|
56
|
+
printf '%s' 'Woods source freshness is unknown: this check could not verify the current source contents.'
|
|
57
|
+
# Only the three literal identifiers above reach this word split.
|
|
58
|
+
for action in $advice; do
|
|
59
|
+
case "$action" in
|
|
60
|
+
deep_check) printf '%s' ' Request woods_status with source_check: deep; the quick reader reached its time limit.' ;;
|
|
61
|
+
fresh_capture) printf '%s' ' Fix the capture or boot evidence reported by woods_status, then run woods-extract full in the application environment.' ;;
|
|
62
|
+
inspect_source_scan) printf '%s' ' Inspect woods_status verification_reasons, source mapping, permissions and scan limits.' ;;
|
|
63
|
+
esac
|
|
64
|
+
done
|
|
65
|
+
printf '\n'
|
|
66
|
+
}
|
|
17
67
|
cwd="$(field "$payload" cwd)"
|
|
18
68
|
[ -n "$cwd" ] && [ -d "$cwd" ] || exit 0
|
|
19
69
|
configured_output="${WOODS_OUTPUT:-tmp/woods}"
|
|
@@ -25,7 +75,10 @@ esac
|
|
|
25
75
|
if command -v jq >/dev/null 2>&1; then
|
|
26
76
|
encoded="$(jq -nr --arg output "$configured_output" '{output:$output,mode:"quick"} | @base64')"
|
|
27
77
|
elif command -v ruby >/dev/null 2>&1; then
|
|
28
|
-
encoded="$(ruby -rjson -rbase64 -e 'print Base64.strict_encode64(JSON.generate(output: ARGV[0], mode: "quick"))' -- "$configured_output")"
|
|
78
|
+
encoded="$(ruby -EUTF-8:UTF-8 -rjson -rbase64 -e 'print Base64.strict_encode64(JSON.generate(output: ARGV[0], mode: "quick"))' -- "$configured_output" 2>/dev/null)" || {
|
|
79
|
+
echo 'Woods source freshness is unknown: hook options could not be encoded. Inspect woods_status.'
|
|
80
|
+
exit 0
|
|
81
|
+
}
|
|
29
82
|
else
|
|
30
83
|
exit 0
|
|
31
84
|
fi
|
|
@@ -63,7 +116,11 @@ status="$(tail -n 1 "$record")"
|
|
|
63
116
|
state="$(field "$status" state)"
|
|
64
117
|
case "$state" in
|
|
65
118
|
current) ;;
|
|
119
|
+
unavailable)
|
|
120
|
+
detail="$(unavailable_summary "$status")"
|
|
121
|
+
printf 'Woods source freshness is unavailable: %s. The code index is usable; inspect woods_status. Repeating a full extraction will not reduce this size.\n' "${detail:-inspect woods_status for the unavailable evidence reason}"
|
|
122
|
+
;;
|
|
66
123
|
drifted) echo 'Woods source freshness is drifted: indexed application inputs differ from the working tree. Run woods-extract full, or inspect woods_status before choosing a targeted refresh.' ;;
|
|
67
|
-
*)
|
|
124
|
+
*) unknown_message "$status" ;;
|
|
68
125
|
esac
|
|
69
126
|
exit 0
|