woods 2.0.0.beta3 → 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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/CONTRIBUTING.md +29 -17
  4. data/README.md +92 -177
  5. data/docs/AGENT_GUIDE.md +26 -4
  6. data/docs/AGENT_SETUP.md +18 -8
  7. data/docs/BACKEND_MATRIX.md +5 -0
  8. data/docs/CONFIGURATION_REFERENCE.md +74 -8
  9. data/docs/CONSOLE_MCP_SETUP.md +45 -2
  10. data/docs/DOCKER_SETUP.md +1 -1
  11. data/docs/EXTRACTOR_REFERENCE.md +9 -1
  12. data/docs/INCREMENTAL_EXTRACTION.md +30 -6
  13. data/docs/MCP_SERVERS.md +57 -2
  14. data/docs/MCP_TOOL_COOKBOOK.md +4 -4
  15. data/docs/MCP_WORKTREE_SETUP.md +43 -83
  16. data/docs/PUBLISHED_INDEX.md +17 -0
  17. data/docs/RETRIEVAL_GUIDE.md +24 -5
  18. data/docs/TROUBLESHOOTING.md +12 -13
  19. data/docs/UPGRADING_TO_2.md +6 -2
  20. data/docs/WATCH_DAEMON.md +18 -8
  21. data/exe/woods-mcp-start +14 -9
  22. data/lib/generators/woods/pgvector_generator.rb +8 -2
  23. data/lib/woods/agent_configuration/applier.rb +5 -3
  24. data/lib/woods/agent_configuration/cli.rb +2 -2
  25. data/lib/woods/agent_configuration/layout.rb +13 -0
  26. data/lib/woods/console/credential_scanner.rb +4 -3
  27. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  28. data/lib/woods/console/embedded_executor.rb +31 -9
  29. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  30. data/lib/woods/console/sql_table_scanner.rb +47 -7
  31. data/lib/woods/console/sql_validator.rb +49 -9
  32. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  33. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  34. data/lib/woods/embedding/indexer.rb +24 -14
  35. data/lib/woods/extractor.rb +45 -12
  36. data/lib/woods/extractors/declared_parent.rb +55 -0
  37. data/lib/woods/extractors/graphql_extractor.rb +2 -11
  38. data/lib/woods/extractors/lib_extractor.rb +10 -8
  39. data/lib/woods/extractors/mailer_extractor.rb +6 -10
  40. data/lib/woods/extractors/model_extractor.rb +1 -15
  41. data/lib/woods/extractors/poro_extractor.rb +10 -8
  42. data/lib/woods/extractors/shared_utility_methods.rb +22 -5
  43. data/lib/woods/mcp/bearer_auth.rb +2 -1
  44. data/lib/woods/mcp/bootstrapper.rb +17 -4
  45. data/lib/woods/mcp/config_resolver.rb +2 -1
  46. data/lib/woods/mcp/index_reader.rb +11 -2
  47. data/lib/woods/mcp/renderers/markdown_renderer.rb +14 -8
  48. data/lib/woods/mcp/renderers/plain_renderer.rb +11 -7
  49. data/lib/woods/mcp/server.rb +22 -28
  50. data/lib/woods/mcp/tool_contract.rb +1 -1
  51. data/lib/woods/mcp/tool_response_renderer.rb +16 -0
  52. data/lib/woods/mcp/traversal_evidence_text.rb +1 -1
  53. data/lib/woods/mcp/traversal_response.rb +22 -0
  54. data/lib/woods/path_dispatcher.rb +6 -5
  55. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  56. data/lib/woods/published_index.rb +2 -2
  57. data/lib/woods/rake_helpers.rb +2 -12
  58. data/lib/woods/retrieval/lexical_assembler.rb +14 -3
  59. data/lib/woods/retrieval/lexical_index.rb +2 -1
  60. data/lib/woods/session_tracer/file_store.rb +6 -1
  61. data/lib/woods/source_inputs/consumer_errors.rb +4 -0
  62. data/lib/woods/storage/pgvector.rb +6 -2
  63. data/lib/woods/temporal/json_snapshot_store.rb +35 -7
  64. data/lib/woods/version.rb +1 -1
  65. data/lib/woods/watch/daemon.rb +18 -4
  66. data/plugin/.claude-plugin/plugin.json +1 -1
  67. data/plugin/hooks/woods-input-rules.sh +4 -4
  68. data/plugin/skills/woods-agent-enable/SKILL.md +7 -1
  69. data/plugin/skills/woods-diagnose/SKILL.md +64 -33
  70. data/plugin/skills/woods-investigate/SKILL.md +51 -12
  71. data/plugin/skills/woods-mcp-config/SKILL.md +11 -11
  72. data/plugin/skills/woods-setup/SKILL.md +14 -11
  73. metadata +8 -5
@@ -27,7 +27,7 @@ module Woods
27
27
  text: { type: 'string' },
28
28
  data: {
29
29
  type: %w[object array string number boolean null],
30
- description: 'Parsed JSON payload when the selected renderer emits JSON.'
30
+ description: 'Structured tool payload, including traversal data in every renderer; otherwise parsed JSON when available.'
31
31
  }
32
32
  },
33
33
  required: ['text'],
@@ -70,6 +70,22 @@ module Woods
70
70
 
71
71
  private
72
72
 
73
+ def traversal_coverage_lines(data)
74
+ coverage = fetch_key(data, :graph_coverage)
75
+ notice = fetch_key(coverage, :notice) if coverage.is_a?(Hash)
76
+ notice ? [notice] : []
77
+ end
78
+
79
+ def traversal_lower_bound_note(data, shown)
80
+ return unless fetch_key(data, :total_is_exact) == false
81
+
82
+ total = fetch_key(data, :nodes_total, shown)
83
+ offset = fetch_key(data, :nodes_offset, 0)
84
+ position = offset.positive? ? " from offset #{offset}" : ''
85
+ "Showing #{shown} of at least #{total}#{position} " \
86
+ "(total unknown: #{fetch_key(data, :partial_reason)})."
87
+ end
88
+
73
89
  def search_completeness_lines(data)
74
90
  evidence = fetch_key(data, :completeness)
75
91
  return [] unless evidence.is_a?(Hash)
@@ -40,7 +40,7 @@ module Woods
40
40
  complete = value(witness, :typed_path_complete) ? 'yes' : 'no'
41
41
  context = value(witness, :context) ? 'yes' : 'no'
42
42
  "#{identifier}: #{value(witness, :impact)}; parent=#{parent}; edge=#{edge}; " \
43
- "typed path complete=#{complete}; context only=#{context}"
43
+ "witness types unambiguous=#{complete}; context only=#{context}"
44
44
  end
45
45
 
46
46
  def self.value(hash, key)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module MCP
5
+ # Response scope is independent of traversal evidence and page selection.
6
+ module TraversalResponse
7
+ COVERAGE = {
8
+ scope: 'published_relationships',
9
+ source_references: 'not_exhaustive',
10
+ notice: 'Graph coverage: published relationships only. Arbitrary method-body constant references ' \
11
+ 'are not exhaustively captured; missing relationships do not prove no callers or dependencies.'
12
+ }.freeze
13
+
14
+ def self.annotate(result)
15
+ return if result[:found] == false
16
+
17
+ result[:graph_coverage] = COVERAGE
18
+ result[:total_is_exact] = !result[:partial]
19
+ end
20
+ end
21
+ end
22
+ end
@@ -16,10 +16,9 @@ module Woods
16
16
  #
17
17
  # * {.file_rules} — file-based extractors, whose per-file method can be
18
18
  # pointed straight at the new path.
19
- # * {.whole_app_rules} — extractors with no per-file entry point (routes,
20
- # middleware, engines, scheduled jobs, state machines, factories,
21
- # events, framework sources). Their trigger paths map to a wholesale
22
- # re-run of that extractor, which is cheap in an already-booted process.
19
+ # * {.whole_app_rules} — extractors needing the complete runtime or source
20
+ # set (routes, middleware, merged Rake tasks, scheduled jobs, etc.). Their
21
+ # trigger paths map to a wholesale re-run of that extractor.
23
22
  #
24
23
  # Class-based extractors (models, controllers, mailers, components,
25
24
  # channels) are deliberately *absent* here. They are reconciled against
@@ -161,7 +160,6 @@ module Woods
161
160
  file_rule(:graphql, :extract_graphql_file,
162
161
  [ex::GraphQLExtractor::GRAPHQL_DIRECTORY], extensions: %w[.rb]),
163
162
  file_rule(:i18n, :extract_i18n_file, ex::I18nExtractor::I18N_DIRECTORIES, extensions: %w[.yml]),
164
- file_rule(:rake_tasks, :extract_rake_file, ex::RakeTaskExtractor::RAKE_DIRECTORIES, extensions: %w[.rake]),
165
163
  file_rule(:view_templates, :extract_view_template_file,
166
164
  ex::ViewTemplateExtractor::VIEW_DIRECTORIES, extensions: view_template_extensions),
167
165
  file_rule(:migrations, :extract_migration_file, %w[db/migrate], recursive: false),
@@ -188,6 +186,9 @@ module Woods
188
186
 
189
187
  def build_whole_app_rules
190
188
  [
189
+ # A task may combine definitions from several files; any change or
190
+ # deletion must reconcile the complete task set, not its primary file.
191
+ whole_app_rule(:rake_tasks, Woods::Extractors::RakeTaskExtractor::RAKE_DIRECTORIES, extensions: %w[.rake]),
191
192
  whole_app_rule(:routes, %w[config/routes], exact_paths: %w[config/routes.rb]),
192
193
  whole_app_rule(:engines, %w[config/routes], exact_paths: %w[config/routes.rb Gemfile.lock]),
193
194
  whole_app_rule(:middleware, %w[config/initializers config/environments],
@@ -21,10 +21,47 @@ module Woods
21
21
  # @return [Hash, nil] string-keyed unit, or nil when this type has no
22
22
  # such identifier
23
23
  def self.call(payload_dir, reader, identifier, type)
24
- dir = Woods::MCP::IndexReader::TYPE_TO_DIR[type]
24
+ dir = directory_for(type)
25
25
  return nil unless dir
26
- return nil unless reader.list_units(type: type).any? { |entry| entry['identifier'] == identifier }
27
26
 
27
+ family = Woods::MCP::IndexReader::DIR_TO_TYPE.fetch(dir)
28
+ return nil unless reader.list_units(type: family).any? { |entry| entry['identifier'] == identifier }
29
+
30
+ unit = read_unit(payload_dir, dir, identifier)
31
+ unit if unit && (unit['type'] == type || type == family)
32
+ end
33
+
34
+ # Resolve actual published types as well as historical directory-family aliases.
35
+ #
36
+ # @param type [String]
37
+ # @return [String, nil]
38
+ def self.directory_for(type)
39
+ Woods::MCP::IndexReader::TYPE_TO_DIR[type] ||
40
+ Woods::MCP::IndexReader::UNIT_TYPES_BY_DIR.find { |_, types| types.include?(type) }&.first
41
+ end
42
+
43
+ # Preserve subtype identity in an index entry from a shared type directory.
44
+ #
45
+ # @param payload_dir [Pathname]
46
+ # @param entry [Hash] published index entry
47
+ # @param dir [String] type directory
48
+ # @param requested_type [String, nil] actual type or family alias
49
+ # @return [Hash, nil] entry with actual type, or nil when filtered out
50
+ def self.entry(payload_dir, entry, dir, requested_type)
51
+ family = Woods::MCP::IndexReader::DIR_TO_TYPE.fetch(dir)
52
+ actual_type = if Woods::MCP::IndexReader::UNIT_TYPES_BY_DIR.fetch(dir).size > 1
53
+ read_unit(payload_dir, dir, entry['identifier'])&.fetch('type')
54
+ else
55
+ family
56
+ end
57
+ return unless actual_type
58
+ return if requested_type && requested_type != family && requested_type != actual_type
59
+
60
+ entry.merge('type' => actual_type)
61
+ end
62
+
63
+ # Read a unit whose directory-index membership has already been established.
64
+ def self.read_unit(payload_dir, dir, identifier)
28
65
  path = payload_dir.join(dir, filename_for(identifier))
29
66
  return nil unless path.file?
30
67
 
@@ -42,7 +79,7 @@ module Woods
42
79
  "#{base}_#{digest}.json"
43
80
  end
44
81
 
45
- private_class_method :filename_for
82
+ private_class_method :filename_for, :read_unit
46
83
  end
47
84
  end
48
85
  end
@@ -167,14 +167,14 @@ module Woods
167
167
  # @return [Array<Hash>]
168
168
  def units(type: nil)
169
169
  dirs = if type
170
- dir = Woods::MCP::IndexReader::TYPE_TO_DIR[type.to_s]
170
+ dir = TypedUnitReader.directory_for(type.to_s)
171
171
  dir ? [dir] : []
172
172
  else
173
173
  Woods::MCP::IndexReader::TYPE_DIRS
174
174
  end
175
175
  dirs.flat_map do |dir|
176
176
  @reader.list_units(type: Woods::MCP::IndexReader::DIR_TO_TYPE[dir])
177
- .map { |entry| entry.merge('type' => Woods::MCP::IndexReader::DIR_TO_TYPE[dir]) }
177
+ .filter_map { |entry| TypedUnitReader.entry(@payload_dir, entry, dir, type&.to_s) }
178
178
  end
179
179
  end
180
180
 
@@ -183,11 +183,8 @@ module Woods
183
183
  woods_sweep_index_dir(output_dir, lock_name)
184
184
  end
185
185
 
186
- # The lock was released (and its file removed) above; drop the guard so
187
- # the directory can empty, then remove the now-empty directory. Another
188
- # writer may legitimately have recreated content between release and here
189
- # — a non-empty directory is left alone rather than forced.
190
- woods_remove_if_empty(output_dir, lock_name)
186
+ # Retain the stable guard and directory: another writer may already hold
187
+ # the guard after release, before it has created its extraction.lock.
191
188
  :cleaned
192
189
  end
193
190
 
@@ -205,13 +202,6 @@ module Woods
205
202
  end
206
203
  end
207
204
 
208
- def woods_remove_if_empty(output_dir, lock_name)
209
- FileUtils.rm_f(output_dir.join(Woods::Coordination::PipelineLock.guard_filename(lock_name)))
210
- Dir.rmdir(output_dir)
211
- rescue SystemCallError
212
- nil
213
- end
214
-
215
205
  # The root containing the Rakefile that loaded this task file.
216
206
  #
217
207
  # `woods:watch_status` intentionally avoids Rails boot, so it cannot ask
@@ -16,9 +16,10 @@ module Woods
16
16
  SourceEvidence.validate_mode!(evidence)
17
17
  raise ArgumentError, 'budget must be a positive Integer' unless budget.is_a?(Integer) && budget.positive?
18
18
 
19
- context = 'Mode: lexical (field-aware BM25; ranked top 20; token counts estimated).'
20
- context += "\nNo lexical matches." if candidates.empty?
21
- context = context[0, budget * 4]
19
+ # Reserve the largest included count before selecting evidence. Replacing
20
+ # it afterward can only shrink the text; do not refill or reorder sources.
21
+ notice = count_notice(candidates.size, candidates.size)
22
+ context = notice[0, budget * 4]
22
23
  sources = []
23
24
  candidates.each do |candidate|
24
25
  unit = candidate.metadata
@@ -50,12 +51,22 @@ module Woods
50
51
  score: candidate.score, matched_fields: candidate.matched_fields, truncated: truncated }
51
52
  break if truncated
52
53
  end
54
+ body = context[notice.length..].to_s
55
+ context = (count_notice(candidates.size, sources.size) + body)[0, budget * 4]
53
56
  AssembledContext.new(context: context, tokens_used: estimate_tokens(context), budget: budget,
54
57
  sources: sources, sections: [:primary], skipped_missing_metadata: 0)
55
58
  end
56
59
 
57
60
  private
58
61
 
62
+ def count_notice(candidates, included)
63
+ text = "Mode: lexical (field-aware BM25; sources included: #{included}; " \
64
+ "candidates considered: #{candidates}; candidate limit: #{LexicalIndex::DEFAULT_LIMIT}; " \
65
+ 'token counts estimated).'
66
+ text += "\nNo lexical matches." if candidates.zero?
67
+ text
68
+ end
69
+
59
70
  def evidence_text(candidate)
60
71
  unit = candidate.metadata
61
72
  source = unit['source_code'].to_s
@@ -13,6 +13,7 @@ module Woods
13
13
  RUNTIME_FIELDS = %w[callbacks associations validations scopes concerns included_modules
14
14
  methods instance_methods class_methods actions routes columns table_name
15
15
  description purpose dependencies].freeze
16
+ DEFAULT_LIMIT = 20
16
17
  K1 = 1.2
17
18
  B = 0.75
18
19
  Document = Struct.new(:key, :unit, :fields, keyword_init: true)
@@ -36,7 +37,7 @@ module Woods
36
37
  @frequencies.freeze
37
38
  end
38
39
 
39
- def execute(query:, limit: 20, type_filter: nil, exclude_types: nil)
40
+ def execute(query:, limit: DEFAULT_LIMIT, type_filter: nil, exclude_types: nil)
40
41
  terms = tokenize(query).uniq
41
42
  candidates = @documents.filter_map do |doc|
42
43
  next unless eligible?(doc.unit, type_filter, exclude_types)
@@ -116,7 +116,8 @@ module Woods
116
116
  def clear(session_id)
117
117
  with_store_lock do
118
118
  FileUtils.rm_f(session_path(session_id))
119
- FileUtils.rm_f(legacy_session_path(session_id))
119
+ legacy = legacy_session_path(session_id)
120
+ FileUtils.rm_f(legacy) if legacy
120
121
  end
121
122
  end
122
123
 
@@ -158,6 +159,10 @@ module Woods
158
159
  def migrate_legacy_session!(session_id)
159
160
  target = session_path(session_id)
160
161
  legacy = legacy_session_path(session_id)
162
+ # Expire each half before a merge or append can refresh its mtime.
163
+ [target, legacy].compact.each do |path|
164
+ FileUtils.rm_f(path) if File.exist?(path) && expired?(path)
165
+ end
161
166
  return target unless legacy && File.exist?(legacy)
162
167
 
163
168
  if File.exist?(target)
@@ -14,6 +14,10 @@ module Woods
14
14
  consumer.instance_variable_set(FLAG, true)
15
15
  end
16
16
 
17
+ def reset(consumer)
18
+ consumer.instance_variable_set(FLAG, false)
19
+ end
20
+
17
21
  def failed?(consumer)
18
22
  consumer && consumer.instance_variable_get(FLAG) == true
19
23
  end
@@ -26,6 +26,7 @@ module Woods
26
26
  class Pgvector # rubocop:disable Metrics/ClassLength
27
27
  include Interface
28
28
 
29
+ MAX_HNSW_DIMENSIONS = 2000
29
30
  TABLE = 'woods_vectors'
30
31
  TABLE_NAME_PATTERN = /\A[a-z_][a-z0-9_]*\z/
31
32
 
@@ -219,9 +220,12 @@ module Woods
219
220
  private
220
221
 
221
222
  def normalize_dimensions(value)
222
- return value if value.is_a?(Integer) && value.positive?
223
+ raise ArgumentError, 'dimensions must be a positive Integer' unless value.is_a?(Integer) && value.positive?
224
+ return value if value <= MAX_HNSW_DIMENSIONS
223
225
 
224
- raise ArgumentError, 'dimensions must be a positive Integer'
226
+ raise ArgumentError,
227
+ "pgvector HNSW vector dimensions must be at most #{MAX_HNSW_DIMENSIONS}, got #{value}. " \
228
+ 'Request a supported provider output width or choose another vector backend.'
225
229
  end
226
230
 
227
231
  def validate_identifier!(name, value, original)
@@ -22,7 +22,7 @@ module Woods
22
22
  #
23
23
  # Implements the same public interface as SnapshotStore so the MCP server
24
24
  # tools work identically.
25
- # Malformed, non-object, or unreadable retained snapshots (including files
25
+ # Malformed, invalidly shaped, or unreadable retained snapshots (including files
26
26
  # removed during retention) are warned about and treated as absent:
27
27
  # +find+ returns nil, +diff+ returns an empty result, and history/list scans
28
28
  # omit the corrupt file.
@@ -95,7 +95,6 @@ module Woods
95
95
  snapshots = load_all_with_units
96
96
  .sort_by { |s| s[:extracted_at] || '' }
97
97
  .reverse
98
- .first(limit)
99
98
 
100
99
  entries = snapshots.flat_map do |snap|
101
100
  snap[:units].values.select { |unit| unit[:identifier] == identifier }.map do |unit|
@@ -150,10 +149,8 @@ module Woods
150
149
  sha = File.basename(path, '.json')
151
150
  next unless sha.match?(/\A[0-9a-f]+\z/i) && File.file?(path)
152
151
 
153
- data = read_snapshot(path) || {}
154
- timestamp = data['extracted_at']
155
- valid = data['git_sha'] == sha && timestamp.is_a?(String)
156
- { git_sha: sha, extracted_at: valid ? timestamp : '', corrupt: !valid }
152
+ data = read_snapshot(path)
153
+ { git_sha: sha, extracted_at: data&.[]('extracted_at') || '', corrupt: data.nil? }
157
154
  end
158
155
  end
159
156
 
@@ -280,7 +277,10 @@ module Woods
280
277
 
281
278
  def read_snapshot(path)
282
279
  data = JSON.parse(AtomicFile.read(path))
283
- raise JSON::ParserError, 'expected a JSON object' unless data.is_a?(Hash)
280
+ validate_snapshot_shape!(data)
281
+ unless data['git_sha'] == File.basename(path, '.json')
282
+ raise JSON::ParserError, 'git_sha does not match the snapshot filename'
283
+ end
284
284
 
285
285
  data
286
286
  rescue JSON::ParserError => e
@@ -291,6 +291,34 @@ module Woods
291
291
  nil
292
292
  end
293
293
 
294
+ # Guard only shapes consumed by conversion, sorting, and the next capture.
295
+ # Legacy files can omit units/timestamps and optional per-unit hashes;
296
+ # bare unit keys still supply identifiers when the record does not.
297
+ def validate_snapshot_shape!(data)
298
+ raise JSON::ParserError, 'expected a JSON object' unless data.is_a?(Hash)
299
+
300
+ sha = data['git_sha']
301
+ unless sha.is_a?(String) && sha.match?(/\A[0-9a-f]+\z/i)
302
+ raise JSON::ParserError, 'expected git_sha to be a hexadecimal string'
303
+ end
304
+
305
+ timestamp = data['extracted_at']
306
+ unless timestamp.nil? || timestamp.is_a?(String)
307
+ raise JSON::ParserError, 'expected extracted_at to be a string or null'
308
+ end
309
+
310
+ validate_snapshot_units!(data['units'])
311
+ end
312
+
313
+ def validate_snapshot_units!(units)
314
+ return if units.nil?
315
+
316
+ raise JSON::ParserError, 'expected units to be an object or null' unless units.is_a?(Hash)
317
+ return if units.each_value.all?(Hash)
318
+
319
+ raise JSON::ParserError, 'expected every unit record to be an object'
320
+ end
321
+
294
322
  # @param exclude_sha [String, nil] SHA to leave out of the result
295
323
  # @return [Hash, nil]
296
324
  def find_latest(exclude_sha: nil)
data/lib/woods/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Woods
4
- VERSION = '2.0.0.beta3'
4
+ VERSION = '2.0.0.beta4'
5
5
  end
@@ -583,7 +583,7 @@ module Woods
583
583
  return unless @catch_up
584
584
 
585
585
  carried = restore_pending
586
- paths = (uncovered_paths + carried).uniq
586
+ paths = (uncovered_paths + carried + vanished_restart_paths).uniq
587
587
  boot_changes = @boot_snapshot ? @boot_snapshot.changed_paths : []
588
588
  enqueue(boot_changes) unless boot_changes.empty?
589
589
  prepare_startup_reconciliation(paths)
@@ -623,7 +623,8 @@ module Woods
623
623
  # graph knows every path it attributed a unit to; any of those gone from
624
624
  # disk means the extractor's sweep has reconciling to do.
625
625
  #
626
- # The daemon only *detects*; it does not name the paths. Naming them
626
+ # Except for boot inputs handled by vanished_restart_paths, the daemon
627
+ # only detects; it does not name the paths. Naming them
627
628
  # would put them in the change set, whose deletions are authoritative for
628
629
  # any unit type — and some registered paths are nominal (on Rails < 7.1,
629
630
  # `ActiveRecord::SchemaMigration` registers a convention path no app
@@ -631,9 +632,22 @@ module Woods
631
632
  # still produces. An empty-change-set run reaches the same ghosts through
632
633
  # the sweep, which carries the bounds that make it safe.
633
634
  def stale_deletions?
634
- root_prefix = "#{@root}/"
635
+ vanished_registered_paths.any?
636
+ end
637
+
638
+ # Deleted boot inputs have no mtime and are absent from a fresh snapshot.
639
+ # Preserve their restart obligation so a boot that already omitted them
640
+ # performs a full extraction. Do not promote nominal model paths to
641
+ # authoritative deletions, even when application reloading is disabled.
642
+ def vanished_restart_paths
643
+ vanished_registered_paths.select do |path|
644
+ @policy.classify(path.delete_prefix("#{@root}/")) == :restart
645
+ end
646
+ end
635
647
 
636
- persisted_registered_paths.any? do |path|
648
+ def vanished_registered_paths
649
+ root_prefix = "#{@root}/"
650
+ persisted_registered_paths.select do |path|
637
651
  path.start_with?(root_prefix) && !File.exist?(path)
638
652
  end
639
653
  end
@@ -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.36",
4
+ "version": "2.3.47",
5
5
  "author": {
6
6
  "name": "lost-in-the"
7
7
  },
@@ -75,10 +75,6 @@ woods_input_action() {
75
75
  case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
76
76
  return
77
77
  fi
78
- if { { { [[ "$path" == *.rake ]]; } && { [[ "$path" == lib/tasks/* ]]; }; }; }; then
79
- case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
80
- return
81
- fi
82
78
  if { { { [[ "$path" == *.html.erb ]] || [[ "$path" == *.erb ]]; } && { [[ "$path" == app/views/* ]]; }; }; }; then
83
79
  case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
84
80
  return
@@ -115,6 +111,10 @@ woods_input_action() {
115
111
  case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
116
112
  return
117
113
  fi
114
+ if { { { [[ "$path" == *.rake ]]; } && { [[ "$path" == lib/tasks/* ]]; }; }; }; then
115
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
116
+ return
117
+ fi
118
118
  if { [[ "$path" == config/routes.rb ]] || { { [[ "$path" == config/routes/* ]]; }; }; }; then
119
119
  case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
120
120
  return
@@ -9,7 +9,7 @@ Installing the gem gives one operator an index; this skill gives every future ag
9
9
 
10
10
  ## Managed configuration availability
11
11
 
12
- `woods-agent-config` (#407) is unreleased after `2.0.0.beta2`. First record the
12
+ `woods-agent-config` (#407) is available in Woods `2.0.0.beta3`. First record the
13
13
  installed version and test `bundle exec woods-agent-config --help` in the
14
14
  selected application bundle. When supported, use its saved setup/update/remove
15
15
  plan and explicit client/scope/root selection; apply the reviewed plan within
@@ -20,6 +20,12 @@ journals contain private configuration bytes. See the canonical
20
20
  for host/Compose preflight, actual Claude file locations, conflict recovery,
21
21
  and removal. Preserve manual setup for older installed versions.
22
22
 
23
+ For concurrent configuration operations, wait for the active operation to
24
+ finish and generate a fresh plan if the saved snapshots changed. Do not remove
25
+ its lock or overwrite the other application's entry. Coordination across
26
+ applications sharing user configuration is unreleased after `2.0.0.beta3`;
27
+ check the installed revision before relying on it.
28
+
23
29
  ## Preflight
24
30
 
25
31
  Confirm Woods actually works before advertising it to every future session: