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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/CONTRIBUTING.md +29 -17
- data/README.md +92 -177
- data/docs/AGENT_GUIDE.md +26 -4
- data/docs/AGENT_SETUP.md +18 -8
- data/docs/BACKEND_MATRIX.md +5 -0
- data/docs/CONFIGURATION_REFERENCE.md +74 -8
- data/docs/CONSOLE_MCP_SETUP.md +45 -2
- data/docs/DOCKER_SETUP.md +1 -1
- data/docs/EXTRACTOR_REFERENCE.md +9 -1
- data/docs/INCREMENTAL_EXTRACTION.md +30 -6
- data/docs/MCP_SERVERS.md +57 -2
- data/docs/MCP_TOOL_COOKBOOK.md +4 -4
- data/docs/MCP_WORKTREE_SETUP.md +43 -83
- data/docs/PUBLISHED_INDEX.md +17 -0
- data/docs/RETRIEVAL_GUIDE.md +24 -5
- data/docs/TROUBLESHOOTING.md +12 -13
- data/docs/UPGRADING_TO_2.md +6 -2
- data/docs/WATCH_DAEMON.md +18 -8
- data/exe/woods-mcp-start +14 -9
- data/lib/generators/woods/pgvector_generator.rb +8 -2
- data/lib/woods/agent_configuration/applier.rb +5 -3
- data/lib/woods/agent_configuration/cli.rb +2 -2
- data/lib/woods/agent_configuration/layout.rb +13 -0
- data/lib/woods/console/credential_scanner.rb +4 -3
- data/lib/woods/console/dispatch_pipeline.rb +7 -0
- data/lib/woods/console/embedded_executor.rb +31 -9
- data/lib/woods/console/sql_noise_stripper.rb +9 -7
- data/lib/woods/console/sql_table_scanner.rb +47 -7
- data/lib/woods/console/sql_validator.rb +49 -9
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/coordination/pipeline_lock.rb +3 -2
- data/lib/woods/embedding/indexer.rb +24 -14
- data/lib/woods/extractor.rb +45 -12
- data/lib/woods/extractors/declared_parent.rb +55 -0
- data/lib/woods/extractors/graphql_extractor.rb +2 -11
- data/lib/woods/extractors/lib_extractor.rb +10 -8
- data/lib/woods/extractors/mailer_extractor.rb +6 -10
- data/lib/woods/extractors/model_extractor.rb +1 -15
- data/lib/woods/extractors/poro_extractor.rb +10 -8
- data/lib/woods/extractors/shared_utility_methods.rb +22 -5
- data/lib/woods/mcp/bearer_auth.rb +2 -1
- data/lib/woods/mcp/bootstrapper.rb +17 -4
- data/lib/woods/mcp/config_resolver.rb +2 -1
- data/lib/woods/mcp/index_reader.rb +11 -2
- data/lib/woods/mcp/renderers/markdown_renderer.rb +14 -8
- data/lib/woods/mcp/renderers/plain_renderer.rb +11 -7
- data/lib/woods/mcp/server.rb +22 -28
- data/lib/woods/mcp/tool_contract.rb +1 -1
- data/lib/woods/mcp/tool_response_renderer.rb +16 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +1 -1
- data/lib/woods/mcp/traversal_response.rb +22 -0
- data/lib/woods/path_dispatcher.rb +6 -5
- data/lib/woods/published_index/typed_unit_reader.rb +40 -3
- data/lib/woods/published_index.rb +2 -2
- data/lib/woods/rake_helpers.rb +2 -12
- data/lib/woods/retrieval/lexical_assembler.rb +14 -3
- data/lib/woods/retrieval/lexical_index.rb +2 -1
- data/lib/woods/session_tracer/file_store.rb +6 -1
- data/lib/woods/source_inputs/consumer_errors.rb +4 -0
- data/lib/woods/storage/pgvector.rb +6 -2
- data/lib/woods/temporal/json_snapshot_store.rb +35 -7
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +18 -4
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/woods-input-rules.sh +4 -4
- data/plugin/skills/woods-agent-enable/SKILL.md +7 -1
- data/plugin/skills/woods-diagnose/SKILL.md +64 -33
- data/plugin/skills/woods-investigate/SKILL.md +51 -12
- data/plugin/skills/woods-mcp-config/SKILL.md +11 -11
- data/plugin/skills/woods-setup/SKILL.md +14 -11
- 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: '
|
|
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
|
-
"
|
|
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
|
|
20
|
-
# middleware,
|
|
21
|
-
#
|
|
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 =
|
|
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 =
|
|
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
|
-
.
|
|
177
|
+
.filter_map { |entry| TypedUnitReader.entry(@payload_dir, entry, dir, type&.to_s) }
|
|
178
178
|
end
|
|
179
179
|
end
|
|
180
180
|
|
data/lib/woods/rake_helpers.rb
CHANGED
|
@@ -183,11 +183,8 @@ module Woods
|
|
|
183
183
|
woods_sweep_index_dir(output_dir, lock_name)
|
|
184
184
|
end
|
|
185
185
|
|
|
186
|
-
#
|
|
187
|
-
# the
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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:
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
data/lib/woods/watch/daemon.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|