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.
Files changed (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/CONTRIBUTING.md +135 -10
  4. data/README.md +1 -1
  5. data/docs/AGENT_GUIDE.md +19 -0
  6. data/docs/AGENT_SETUP.md +22 -2
  7. data/docs/BACKEND_MATRIX.md +7 -0
  8. data/docs/CLIENT_HOOKS.md +6 -0
  9. data/docs/CONFIGURATION_REFERENCE.md +133 -18
  10. data/docs/CONSOLE_MCP_SETUP.md +141 -12
  11. data/docs/EMBEDDING_MODELS.md +16 -19
  12. data/docs/EXTRACTOR_REFERENCE.md +219 -21
  13. data/docs/FAQ.md +11 -25
  14. data/docs/GETTING_STARTED.md +7 -1
  15. data/docs/INCREMENTAL_EXTRACTION.md +261 -19
  16. data/docs/INDEX_LAYOUT.md +5 -0
  17. data/docs/INTERNALS.md +9 -0
  18. data/docs/MCP_HTTP_TRANSPORT.md +59 -2
  19. data/docs/MCP_SERVERS.md +87 -8
  20. data/docs/MCP_TOOL_COOKBOOK.md +13 -55
  21. data/docs/NOTION_INTEGRATION.md +7 -1
  22. data/docs/PUBLISHED_INDEX.md +6 -0
  23. data/docs/README.md +6 -1
  24. data/docs/RETRIEVAL_GUIDE.md +17 -0
  25. data/docs/SOURCE_FRESHNESS.md +157 -5
  26. data/docs/TOKEN_BENCHMARK.md +10 -18
  27. data/docs/TROUBLESHOOTING.md +70 -14
  28. data/docs/UNBLOCKED_INTEGRATION.md +60 -8
  29. data/docs/UPGRADING_TO_2.md +184 -9
  30. data/docs/WATCH_DAEMON.md +97 -14
  31. data/exe/woods-console-mcp +2 -2
  32. data/exe/woods-mcp-http +16 -9
  33. data/lib/generators/woods/templates/woods.rb.tt +2 -1
  34. data/lib/tasks/woods.rake +23 -7
  35. data/lib/tasks/woods_checks.rake +2 -2
  36. data/lib/woods/agent_configuration/cli.rb +1 -1
  37. data/lib/woods/agent_configuration/layout.rb +16 -2
  38. data/lib/woods/agent_configuration/plan.rb +13 -3
  39. data/lib/woods/agent_configuration/planner_validation.rb +4 -2
  40. data/lib/woods/agent_configuration/preflight.rb +5 -3
  41. data/lib/woods/builder.rb +17 -57
  42. data/lib/woods/cache/cache_middleware.rb +68 -41
  43. data/lib/woods/chunking/contributor_chunks.rb +119 -0
  44. data/lib/woods/chunking/semantic_chunker.rb +44 -21
  45. data/lib/woods/console/adapter_family.rb +39 -0
  46. data/lib/woods/console/connection_manager.rb +56 -3
  47. data/lib/woods/console/credential_index.rb +33 -3
  48. data/lib/woods/console/embedded_executor.rb +431 -48
  49. data/lib/woods/console/model_validator.rb +8 -0
  50. data/lib/woods/console/rack_middleware.rb +68 -11
  51. data/lib/woods/console/redactor.rb +24 -10
  52. data/lib/woods/console/safe_context.rb +44 -7
  53. data/lib/woods/console/sql_noise_stripper.rb +41 -12
  54. data/lib/woods/console/sql_table_scanner.rb +45 -34
  55. data/lib/woods/console/sql_validator.rb +37 -2
  56. data/lib/woods/dependency_graph.rb +34 -10
  57. data/lib/woods/embedding/fake.rb +12 -0
  58. data/lib/woods/embedding/indexer.rb +195 -98
  59. data/lib/woods/embedding/input_budget.rb +67 -0
  60. data/lib/woods/embedding/openai.rb +70 -20
  61. data/lib/woods/embedding/provider.rb +37 -25
  62. data/lib/woods/embedding/text_preparer.rb +76 -32
  63. data/lib/woods/embedding/token_counter.rb +18 -81
  64. data/lib/woods/embedding/vector_configuration.rb +48 -0
  65. data/lib/woods/extraction_identities.rb +175 -0
  66. data/lib/woods/extractor.rb +304 -107
  67. data/lib/woods/extractors/action_cable_extractor.rb +8 -3
  68. data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
  69. data/lib/woods/extractors/class_declarations.rb +121 -0
  70. data/lib/woods/extractors/configuration_extractor.rb +11 -3
  71. data/lib/woods/extractors/declaration_ancestry.rb +92 -0
  72. data/lib/woods/extractors/event_extractor.rb +8 -0
  73. data/lib/woods/extractors/graphql_extractor.rb +134 -77
  74. data/lib/woods/extractors/job_extractor.rb +5 -1
  75. data/lib/woods/extractors/lib_extractor.rb +132 -15
  76. data/lib/woods/extractors/mailer_extractor.rb +3 -5
  77. data/lib/woods/extractors/manager_extractor.rb +7 -21
  78. data/lib/woods/extractors/migration_declaration.rb +87 -0
  79. data/lib/woods/extractors/migration_extractor.rb +5 -39
  80. data/lib/woods/extractors/phlex_extractor.rb +6 -2
  81. data/lib/woods/extractors/policy_extractor.rb +9 -5
  82. data/lib/woods/extractors/poro_extractor.rb +112 -53
  83. data/lib/woods/extractors/pundit_extractor.rb +11 -6
  84. data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
  85. data/lib/woods/extractors/serializer_extractor.rb +34 -22
  86. data/lib/woods/extractors/shared_utility_methods.rb +18 -1
  87. data/lib/woods/extractors/source_nesting.rb +142 -106
  88. data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
  89. data/lib/woods/extractors/state_machine_extractor.rb +46 -40
  90. data/lib/woods/extractors/view_component_extractor.rb +9 -7
  91. data/lib/woods/flow_assembler.rb +4 -1
  92. data/lib/woods/generation.rb +25 -0
  93. data/lib/woods/hooks/context_hint.rb +7 -2
  94. data/lib/woods/mcp/bearer_auth.rb +1 -1
  95. data/lib/woods/mcp/bootstrapper.rb +33 -7
  96. data/lib/woods/mcp/config_resolver.rb +26 -7
  97. data/lib/woods/mcp/index_reader.rb +125 -24
  98. data/lib/woods/mcp/index_reader_pinning.rb +16 -0
  99. data/lib/woods/mcp/origin_guard.rb +24 -77
  100. data/lib/woods/mcp/origin_policy.rb +124 -0
  101. data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
  102. data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
  103. data/lib/woods/mcp/search_results.rb +7 -1
  104. data/lib/woods/mcp/server.rb +24 -4
  105. data/lib/woods/module_reconciliation.rb +151 -0
  106. data/lib/woods/path_dispatcher.rb +7 -2
  107. data/lib/woods/railtie_support.rb +8 -0
  108. data/lib/woods/rake_helpers.rb +43 -11
  109. data/lib/woods/release.rb +1 -1
  110. data/lib/woods/resilience/index_validator.rb +8 -3
  111. data/lib/woods/resilience/retryable_provider.rb +18 -1
  112. data/lib/woods/resolved_config.rb +68 -8
  113. data/lib/woods/retrieval/context_assembler.rb +3 -3
  114. data/lib/woods/retrieval/lexical_assembler.rb +3 -2
  115. data/lib/woods/retrieval/scope.rb +18 -2
  116. data/lib/woods/retrieval/source_evidence.rb +14 -2
  117. data/lib/woods/source_contributor_validation.rb +78 -0
  118. data/lib/woods/source_contributors.rb +116 -0
  119. data/lib/woods/source_inputs/handoff.rb +37 -0
  120. data/lib/woods/source_inputs/launcher.rb +53 -13
  121. data/lib/woods/source_inputs/manifest.rb +84 -3
  122. data/lib/woods/source_inputs/private_key.rb +44 -12
  123. data/lib/woods/source_inputs/scanner.rb +98 -27
  124. data/lib/woods/source_inputs/scopes.rb +1 -1
  125. data/lib/woods/source_inputs/session.rb +147 -15
  126. data/lib/woods/source_inputs/stable_reader.rb +127 -0
  127. data/lib/woods/source_inputs/status.rb +40 -8
  128. data/lib/woods/source_inputs/verifier.rb +28 -5
  129. data/lib/woods/source_path_encoding.rb +33 -0
  130. data/lib/woods/source_references/cache.rb +284 -0
  131. data/lib/woods/source_references/collector.rb +120 -0
  132. data/lib/woods/source_references/extraction.rb +185 -0
  133. data/lib/woods/source_references/inputs.rb +134 -0
  134. data/lib/woods/source_references/parser_adapter.rb +134 -0
  135. data/lib/woods/source_references/pass.rb +152 -0
  136. data/lib/woods/source_references/prism_adapter.rb +116 -0
  137. data/lib/woods/source_references/registry.rb +178 -0
  138. data/lib/woods/source_references/runtime_lookup.rb +127 -0
  139. data/lib/woods/source_references/value_class.rb +82 -0
  140. data/lib/woods/storage/metadata_store.rb +4 -1
  141. data/lib/woods/storage/qdrant.rb +2 -2
  142. data/lib/woods/unblocked/client.rb +12 -7
  143. data/lib/woods/unblocked/document_builder.rb +4 -1
  144. data/lib/woods/unblocked/exporter.rb +127 -37
  145. data/lib/woods/unblocked/sync_manifest.rb +137 -21
  146. data/lib/woods/unblocked/uri_migration.rb +105 -0
  147. data/lib/woods/util/host_guard.rb +3 -2
  148. data/lib/woods/version.rb +1 -1
  149. data/lib/woods/watch/catch_up.rb +138 -0
  150. data/lib/woods/watch/claim_lease.rb +150 -0
  151. data/lib/woods/watch/cli.rb +26 -2
  152. data/lib/woods/watch/daemon.rb +80 -59
  153. data/lib/woods/watch/installation/options.rb +1 -1
  154. data/lib/woods/watch/installation/receipt.rb +6 -1
  155. data/lib/woods/watch/managed_child.rb +1 -1
  156. data/lib/woods/watch/supervisor.rb +1 -1
  157. data/lib/woods/watch/tree_scan.rb +14 -2
  158. data/plugin/.claude-plugin/plugin.json +1 -1
  159. data/plugin/hooks/adapters/normalize.rb +3 -2
  160. data/plugin/hooks/woods-input-rules.sh +4 -0
  161. data/plugin/hooks/woods-refresh.sh +15 -7
  162. data/plugin/hooks/woods-session-start.sh +60 -3
  163. data/plugin/skills/woods-diagnose/SKILL.md +336 -2
  164. data/plugin/skills/woods-investigate/SKILL.md +11 -0
  165. data/plugin/skills/woods-mcp-config/SKILL.md +86 -0
  166. data/plugin/skills/woods-setup/SKILL.md +56 -1
  167. metadata +34 -5
@@ -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 = 'watch_claim.json'
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 && @startup_full
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 { @pending.merge(change_set.absolute_paths) }
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
- # The generation file is rewritten as the last act of every successful
617
- # extraction, so its mtime is "when this index was last known good".
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
- paths = (uncovered_paths + carried + vanished_restart_paths).uniq
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 = restart_paths.any?
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
- watermark = index_watermark
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 uncovered?(path, watermark)
730
- return true if watermark.nil?
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: environment boot covers startup changes — full extraction')
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
- # A no-op incremental deliberately does not bump, so this only fires when
1075
- # units were actually written.
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. Where the filesystem refuses the
1207
- # lock (some network mounts) the loop runs unserialized, as before.
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
- yield
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(File.read(claim_path))
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
- FileUtils.rm_f(claim_path) if own_claim?
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(File.read(claim_path))
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
- true
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.nil? || value.empty?
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
- return if document.fingerprint == expected
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.to_s
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.54",
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
- puts JSON.generate(WoodsHookEvent.normalize(ARGV.fetch(0), JSON.parse($stdin.read(1_048_577))))
59
- rescue JSON::ParserError, ArgumentError, KeyError, TypeError
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" | ruby -rjson -e 'print JSON.parse($stdin.read).fetch("root")'
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" | ruby -rjson -e 'JSON.parse($stdin.read).fetch("events").each { |e| print e.fetch("operation"), "\0", e.fetch("path"), "\0" }'
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
- ruby -rjson -e 'puts JSON.generate(path: ARGV[0], operation: "update")' -- "$1" >"$event.tmp" || return 1
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" | ruby -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
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
- ruby -rjson -rbase64 -e '
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="$(ruby -rjson -e 'v = JSON.parse(File.read(ARGV[0])); puts(v.is_a?(Array) ? v.size : 1)' "$candidate")" || break
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
- *) echo 'Woods source freshness is unknown: this generation lacks complete verified source evidence. Inspect woods_status; use woods-extract full for a fresh capture.' ;;
124
+ *) unknown_message "$status" ;;
68
125
  esac
69
126
  exit 0