woods 2.0.0.beta1 → 2.0.0.beta3
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 +400 -1
- data/CONTRIBUTING.md +224 -9
- data/README.md +7 -3
- data/SECURITY.md +9 -6
- data/docs/AGENT_GUIDE.md +83 -4
- data/docs/AGENT_SETUP.md +82 -1
- data/docs/BACKEND_MATRIX.md +20 -0
- data/docs/CLIENT_HOOKS.md +111 -0
- data/docs/CONFIGURATION_REFERENCE.md +233 -13
- data/docs/CONSOLE_MCP_SETUP.md +35 -5
- data/docs/DOCKER_SETUP.md +21 -2
- data/docs/EVALUATION.md +464 -1
- data/docs/EXTRACTOR_REFERENCE.md +36 -5
- data/docs/FAQ.md +11 -12
- data/docs/GETTING_STARTED.md +17 -5
- data/docs/INCREMENTAL_EXTRACTION.md +158 -2
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +15 -7
- data/docs/MCP_SERVERS.md +221 -5
- data/docs/MCP_TOOL_COOKBOOK.md +33 -18
- data/docs/NOTION_INTEGRATION.md +13 -0
- data/docs/OBSIDIAN_INTEGRATION.md +57 -9
- data/docs/PUBLISHED_INDEX.md +71 -0
- data/docs/README.md +7 -0
- data/docs/RETRIEVAL_GUIDE.md +253 -11
- data/docs/RUNTIME_TRACING.md +71 -0
- data/docs/SOURCE_FRESHNESS.md +143 -0
- data/docs/TROUBLESHOOTING.md +117 -5
- data/docs/UNBLOCKED_INTEGRATION.md +25 -0
- data/docs/UPGRADING_TO_2.md +44 -22
- data/docs/WATCH_DAEMON.md +259 -59
- data/exe/woods-agent-config +6 -0
- data/exe/woods-extract +5 -0
- data/exe/woods-hook-context +6 -0
- data/lib/generators/woods/templates/woods.rb.tt +1 -3
- data/lib/tasks/woods.rake +47 -397
- data/lib/woods/agent_configuration/applier.rb +133 -0
- data/lib/woods/agent_configuration/cli.rb +101 -0
- data/lib/woods/agent_configuration/cli_options.rb +29 -0
- data/lib/woods/agent_configuration/document.rb +105 -0
- data/lib/woods/agent_configuration/error.rb +7 -0
- data/lib/woods/agent_configuration/launcher.rb +75 -0
- data/lib/woods/agent_configuration/layout.rb +59 -0
- data/lib/woods/agent_configuration/managed_section.rb +62 -0
- data/lib/woods/agent_configuration/plan.rb +98 -0
- data/lib/woods/agent_configuration/plan_diff.rb +38 -0
- data/lib/woods/agent_configuration/planned_files.rb +61 -0
- data/lib/woods/agent_configuration/planner.rb +63 -0
- data/lib/woods/agent_configuration/planner_validation.rb +77 -0
- data/lib/woods/agent_configuration/preflight.rb +100 -0
- data/lib/woods/agent_configuration/recovery.rb +49 -0
- data/lib/woods/ast/node.rb +2 -0
- data/lib/woods/ast/parser.rb +38 -5
- data/lib/woods/atomic_file.rb +133 -3
- data/lib/woods/builder.rb +21 -5
- data/lib/woods/cache/cache_middleware.rb +28 -7
- data/lib/woods/cache/cache_store.rb +4 -5
- data/lib/woods/change_set.rb +5 -4
- data/lib/woods/console/credential_index.rb +20 -2
- data/lib/woods/console/credential_scanner.rb +14 -14
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/embedded_executor.rb +1 -1
- data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
- data/lib/woods/console/rack_middleware.rb +22 -13
- data/lib/woods/console/server.rb +18 -16
- data/lib/woods/dependency_graph.rb +65 -13
- data/lib/woods/embedding/corpus.rb +94 -0
- data/lib/woods/embedding/indexer.rb +90 -46
- data/lib/woods/embedding/openai.rb +17 -6
- data/lib/woods/evaluation/ablation_executor.rb +6 -1
- data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
- data/lib/woods/export/typed_reader.rb +56 -0
- data/lib/woods/extractor.rb +557 -228
- data/lib/woods/extractors/action_cable_extractor.rb +3 -1
- data/lib/woods/extractors/behavioral_profile.rb +9 -7
- data/lib/woods/extractors/caching_extractor.rb +3 -1
- data/lib/woods/extractors/concern_extractor.rb +64 -6
- data/lib/woods/extractors/configuration_extractor.rb +7 -3
- data/lib/woods/extractors/controller_extractor.rb +13 -4
- data/lib/woods/extractors/database_view_extractor.rb +3 -1
- data/lib/woods/extractors/decorator_extractor.rb +3 -1
- data/lib/woods/extractors/engine_extractor.rb +3 -1
- data/lib/woods/extractors/event_extractor.rb +4 -2
- data/lib/woods/extractors/factory_extractor.rb +3 -1
- data/lib/woods/extractors/graphql_extractor.rb +8 -2
- data/lib/woods/extractors/i18n_extractor.rb +3 -1
- data/lib/woods/extractors/job_extractor.rb +6 -19
- data/lib/woods/extractors/lib_extractor.rb +3 -1
- data/lib/woods/extractors/mailer_extractor.rb +20 -5
- data/lib/woods/extractors/manager_extractor.rb +3 -1
- data/lib/woods/extractors/method_parameters.rb +53 -0
- data/lib/woods/extractors/middleware_argument.rb +65 -0
- data/lib/woods/extractors/middleware_extractor.rb +9 -3
- data/lib/woods/extractors/migration_extractor.rb +3 -1
- data/lib/woods/extractors/model_extractor.rb +39 -33
- data/lib/woods/extractors/package_extractor.rb +24 -4
- data/lib/woods/extractors/phlex_extractor.rb +3 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -1
- data/lib/woods/extractors/poro_extractor.rb +3 -1
- data/lib/woods/extractors/pundit_extractor.rb +3 -1
- data/lib/woods/extractors/rails_source_extractor.rb +4 -2
- data/lib/woods/extractors/rake_task_extractor.rb +4 -2
- data/lib/woods/extractors/route_extractor.rb +3 -1
- data/lib/woods/extractors/route_helper_resolver.rb +10 -33
- data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
- data/lib/woods/extractors/serializer_extractor.rb +4 -2
- data/lib/woods/extractors/service_extractor.rb +3 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
- data/lib/woods/extractors/shared_utility_methods.rb +27 -15
- data/lib/woods/extractors/source_nesting.rb +1 -1
- data/lib/woods/extractors/state_machine_extractor.rb +3 -1
- data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
- data/lib/woods/extractors/validator_extractor.rb +3 -1
- data/lib/woods/extractors/view_component_extractor.rb +3 -1
- data/lib/woods/extractors/view_template_extractor.rb +3 -1
- data/lib/woods/flow_assembler.rb +87 -8
- data/lib/woods/flow_precomputer.rb +44 -7
- data/lib/woods/gem_mapper.rb +2 -0
- data/lib/woods/git_history.rb +116 -0
- data/lib/woods/graph_analyzer.rb +195 -63
- data/lib/woods/hooks/context_cli.rb +54 -0
- data/lib/woods/hooks/context_event.rb +88 -0
- data/lib/woods/hooks/context_hint.rb +73 -0
- data/lib/woods/hooks/context_impact.rb +77 -0
- data/lib/woods/hooks/context_output.rb +47 -0
- data/lib/woods/hooks/context_state.rb +102 -0
- data/lib/woods/hooks/refresh.rb +79 -0
- data/lib/woods/hooks/rule_projection.rb +78 -0
- data/lib/woods/input_rules.rb +19 -0
- data/lib/woods/mcp/bearer_auth.rb +20 -12
- data/lib/woods/mcp/bootstrapper.rb +62 -0
- data/lib/woods/mcp/index_reader.rb +323 -160
- data/lib/woods/mcp/initialization_guidance.rb +27 -0
- data/lib/woods/mcp/origin_guard.rb +17 -9
- data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
- data/lib/woods/mcp/search_results.rb +74 -0
- data/lib/woods/mcp/server.rb +158 -37
- data/lib/woods/mcp/tool_contract.rb +2 -0
- data/lib/woods/mcp/tool_response_renderer.rb +25 -0
- data/lib/woods/mcp/traversal_evidence.rb +113 -0
- data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
- data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
- data/lib/woods/notion/exporter.rb +56 -17
- data/lib/woods/obsidian/destination_plan.rb +98 -0
- data/lib/woods/obsidian/name_mapper.rb +19 -3
- data/lib/woods/obsidian/note_builder.rb +19 -10
- data/lib/woods/obsidian/vault_exporter.rb +88 -32
- data/lib/woods/operator/pipeline_guard.rb +18 -13
- data/lib/woods/path_dispatcher.rb +7 -1
- data/lib/woods/payload_store.rb +29 -15
- data/lib/woods/railtie.rb +3 -3
- data/lib/woods/railtie_support.rb +12 -12
- data/lib/woods/rake_helpers.rb +392 -0
- data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
- data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
- data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
- data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
- data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
- data/lib/woods/resilience/index_validator.rb +112 -23
- data/lib/woods/retrieval/context_assembler.rb +50 -15
- data/lib/woods/retrieval/lexical_assembler.rb +73 -0
- data/lib/woods/retrieval/lexical_index.rb +119 -0
- data/lib/woods/retrieval/ranker.rb +4 -2
- data/lib/woods/retrieval/scope.rb +108 -0
- data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
- data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
- data/lib/woods/retrieval/search_executor.rb +86 -27
- data/lib/woods/retrieval/source_evidence.rb +200 -0
- data/lib/woods/retriever.rb +98 -22
- data/lib/woods/ruby_analyzer/trace_enricher.rb +80 -38
- data/lib/woods/session_tracer/middleware.rb +10 -12
- data/lib/woods/session_tracer/redis_store.rb +22 -6
- data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
- data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
- data/lib/woods/session_tracer/unit_resolver.rb +63 -0
- data/lib/woods/source_inputs/consumer_errors.rb +27 -0
- data/lib/woods/source_inputs/handoff.rb +102 -0
- data/lib/woods/source_inputs/launcher.rb +157 -0
- data/lib/woods/source_inputs/manifest.rb +124 -0
- data/lib/woods/source_inputs/private_key.rb +55 -0
- data/lib/woods/source_inputs/scanner.rb +171 -0
- data/lib/woods/source_inputs/scopes.rb +71 -0
- data/lib/woods/source_inputs/session.rb +214 -0
- data/lib/woods/source_inputs/status.rb +84 -0
- data/lib/woods/source_inputs/verifier.rb +107 -0
- data/lib/woods/storage/metadata_store.rb +25 -25
- data/lib/woods/storage/pgvector.rb +29 -8
- data/lib/woods/storage/qdrant.rb +17 -7
- data/lib/woods/storage/vector_store.rb +18 -6
- data/lib/woods/tasks.rb +3 -2
- data/lib/woods/temporal/json_snapshot_store.rb +29 -8
- data/lib/woods/unblocked/exporter.rb +59 -70
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/boot_snapshot.rb +52 -0
- data/lib/woods/watch/daemon.rb +136 -28
- data/lib/woods/watch/listen_watcher.rb +4 -0
- data/lib/woods/watch/polling_watcher.rb +5 -1
- data/lib/woods/watch/status.rb +20 -15
- data/lib/woods/watch/tree_scan.rb +21 -13
- data/lib/woods/watch/watcher.rb +4 -1
- data/lib/woods.rb +135 -11
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.jq +15 -0
- data/plugin/hooks/adapters/normalize.rb +63 -0
- data/plugin/hooks/hooks.json +20 -0
- data/plugin/hooks/woods-context.sh +50 -0
- data/plugin/hooks/woods-input-rules.sh +159 -0
- data/plugin/hooks/woods-opencode.mjs +65 -0
- data/plugin/hooks/woods-post-edit.sh +2 -225
- data/plugin/hooks/woods-refresh.sh +260 -0
- data/plugin/hooks/woods-session-start.sh +47 -55
- data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
- data/plugin/skills/woods-diagnose/SKILL.md +288 -1
- data/plugin/skills/woods-investigate/SKILL.md +106 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
- data/plugin/skills/woods-setup/SKILL.md +107 -6
- metadata +84 -5
|
@@ -10,6 +10,7 @@ require_relative 'errors'
|
|
|
10
10
|
require_relative 'name_mapper'
|
|
11
11
|
require_relative 'note_builder'
|
|
12
12
|
require_relative 'vault_assets'
|
|
13
|
+
require_relative 'destination_plan'
|
|
13
14
|
|
|
14
15
|
module Woods
|
|
15
16
|
module Obsidian
|
|
@@ -72,7 +73,7 @@ module Woods
|
|
|
72
73
|
def export_all
|
|
73
74
|
with_pinned_index do
|
|
74
75
|
graph = @reader.raw_graph_data || {}
|
|
75
|
-
|
|
76
|
+
prepare_graph(graph)
|
|
76
77
|
@pagerank = graph['pagerank'] || {}
|
|
77
78
|
analysis = safe_graph_analysis
|
|
78
79
|
owned = vault_owned_at_start?
|
|
@@ -82,16 +83,24 @@ module Woods
|
|
|
82
83
|
edge_map = build_edge_map(emitted.to_set, graph)
|
|
83
84
|
builder = build_note_builder(mapper, analysis)
|
|
84
85
|
|
|
86
|
+
@planned_writes = {}
|
|
85
87
|
written = Set.new
|
|
86
88
|
errors = []
|
|
87
89
|
exported = write_notes(emitted, mapper, edge_map, builder, written, errors)
|
|
88
90
|
indexes = write_indexes(emitted, mapper, written)
|
|
89
91
|
write_sidecar(emitted, mapper, graph, analysis, written)
|
|
90
92
|
write_owned_assets(emitted) if owned
|
|
93
|
+
DestinationPlan.new(@vault, @planned_writes, managed_note: method(:managed_marker?)).commit
|
|
91
94
|
|
|
92
|
-
swept = owned && errors.empty? ? sweep(written) : 0
|
|
95
|
+
swept = owned && errors.empty? && skipped.zero? ? sweep(written) : 0
|
|
93
96
|
warn_foreign unless owned
|
|
94
97
|
{ exported: exported, indexes: indexes, swept: swept, skipped: skipped, errors: cap_errors(errors) }
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
log " Export stopped: #{e.class}: #{e.message}"
|
|
100
|
+
{ exported: 0, indexes: 0, swept: 0, skipped: skipped || 0,
|
|
101
|
+
errors: cap_errors(Array(errors) + ["#{e.class}: #{e.message}"]) }
|
|
102
|
+
ensure
|
|
103
|
+
@planned_writes = nil
|
|
95
104
|
end
|
|
96
105
|
end
|
|
97
106
|
|
|
@@ -119,19 +128,35 @@ module Woods
|
|
|
119
128
|
|
|
120
129
|
# ── Selection / mapping ──────────────────────────────────────────
|
|
121
130
|
|
|
131
|
+
# Keep typed identity local to the exporter. Public identifiers and graph
|
|
132
|
+
# artifacts retain their original shape; variants have their own outgoing
|
|
133
|
+
# relationships, while persisted edge targets remain bare identifiers.
|
|
134
|
+
def prepare_graph(graph)
|
|
135
|
+
@primary = graph['nodes'] || {}
|
|
136
|
+
@nodes = @primary.to_h { |id, node| [[id, node['type']], node] }
|
|
137
|
+
@forward = @nodes.keys.to_h { |key| [key, Array(graph.dig('edges', key.first))] }
|
|
138
|
+
Array(graph['variants']).grep(Hash).each do |record|
|
|
139
|
+
key = [record['identifier'], record['type']]
|
|
140
|
+
next if key.any?(&:nil?) || @nodes.key?(key)
|
|
141
|
+
|
|
142
|
+
@nodes[key] = record
|
|
143
|
+
@forward[key] = Array(record['edges'])
|
|
144
|
+
end
|
|
145
|
+
@identifiers = @nodes.keys.to_h { |key| [key, key.first] }
|
|
146
|
+
@keys_by_identifier = @nodes.keys.group_by(&:first)
|
|
147
|
+
@ambiguous = @keys_by_identifier.select { |_, keys| keys.size > 1 }.keys.to_set
|
|
148
|
+
end
|
|
149
|
+
|
|
122
150
|
# The emitted set = graph nodes of an exportable type that have a loadable
|
|
123
151
|
# unit file. Confirming the file loads (not just that the index lists it)
|
|
124
152
|
# guarantees every wikilink target is backed by a real note — a node listed
|
|
125
153
|
# in the index but with no readable unit is dropped (counted as skipped).
|
|
126
154
|
#
|
|
127
|
-
# @return [Array(Array<String
|
|
155
|
+
# @return [Array(Array<Array<String>>, Integer)] [sorted typed keys, skipped count]
|
|
128
156
|
def partition_emitted
|
|
129
|
-
known = known_ids
|
|
130
157
|
candidates = @nodes.keys.select do |id|
|
|
131
158
|
type = @nodes[id]['type']
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
known.include?(id)
|
|
159
|
+
@include_framework || !FRAMEWORK_TYPES.include?(type)
|
|
135
160
|
end.sort
|
|
136
161
|
|
|
137
162
|
# Load each candidate once and keep it: confirms the unit is readable
|
|
@@ -151,18 +176,20 @@ module Woods
|
|
|
151
176
|
# Load a unit, treating an unreadable/corrupt file as absent rather than
|
|
152
177
|
# letting one bad JSON parse abort the whole export.
|
|
153
178
|
def load_unit(id)
|
|
154
|
-
@
|
|
179
|
+
unit = if @ambiguous.include?(id.first)
|
|
180
|
+
@reader.find_unit(id.first, type: id.last)
|
|
181
|
+
else
|
|
182
|
+
@reader.find_unit(id.first)
|
|
183
|
+
end
|
|
184
|
+
unit if unit && unit['identifier'] == id.first && unit['type'] == id.last
|
|
155
185
|
rescue StandardError => e
|
|
156
186
|
log " skipped #{id}: #{e.class}: #{e.message}"
|
|
157
187
|
nil
|
|
158
188
|
end
|
|
159
189
|
|
|
160
|
-
def known_ids
|
|
161
|
-
@known_ids ||= @reader.list_units.to_set { |entry| entry['identifier'] }
|
|
162
|
-
end
|
|
163
|
-
|
|
164
190
|
def build_mapper(emitted)
|
|
165
|
-
NameMapper.new(emitted.to_h { |id| [id, dir_for(@nodes[id]['type'])] }
|
|
191
|
+
NameMapper.new(emitted.to_h { |id| [id, dir_for(@nodes[id]['type'])] },
|
|
192
|
+
identifiers: @identifiers, ambiguous_identifiers: @ambiguous)
|
|
166
193
|
end
|
|
167
194
|
|
|
168
195
|
def dir_for(type)
|
|
@@ -182,25 +209,37 @@ module Woods
|
|
|
182
209
|
|
|
183
210
|
def build_note_builder(mapper, analysis)
|
|
184
211
|
NoteBuilder.new(name_mapper: mapper, nodes: @nodes, pagerank: @pagerank,
|
|
185
|
-
analysis: analysis, include_source: @include_source, scanner: scanner
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
#
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
212
|
+
analysis: analysis, include_source: @include_source, scanner: scanner,
|
|
213
|
+
identifiers: @identifiers, ambiguous_identifiers: @ambiguous)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Reverse links must come from resolved forward edges: the legacy reverse
|
|
217
|
+
# index merges source types, and cannot assign them to a particular note.
|
|
218
|
+
# A bare target naming several graph types remains ambiguous even if only
|
|
219
|
+
# one sibling is exportable. Do not invent a typed relationship.
|
|
220
|
+
def build_edge_map(emitted, _graph)
|
|
221
|
+
@ambiguous_edges = Set.new
|
|
222
|
+
map = emitted.to_h { |id| [id, { depends_on: [], used_by: [] }] }
|
|
223
|
+
emitted.each do |id|
|
|
224
|
+
deps = @forward[id].filter_map { |edge| forward_edge(edge, id, emitted) }
|
|
225
|
+
.uniq { |edge| [edge[:target], edge[:via]] }
|
|
226
|
+
map[id][:depends_on] = deps
|
|
227
|
+
deps.each { |edge| map[edge[:target]][:used_by] << id }
|
|
198
228
|
end
|
|
229
|
+
map.each_value { |edges| edges[:used_by].uniq! }
|
|
230
|
+
log " omitted #{@ambiguous_edges.size} ambiguous dependency relationships" unless @ambiguous_edges.empty?
|
|
231
|
+
map
|
|
199
232
|
end
|
|
200
233
|
|
|
201
234
|
def forward_edge(edge, source_id, emitted)
|
|
202
235
|
edge = { 'target' => edge } unless edge.is_a?(Hash)
|
|
203
|
-
|
|
236
|
+
identifier = edge['target']
|
|
237
|
+
if @ambiguous.include?(identifier)
|
|
238
|
+
@ambiguous_edges << [source_id, identifier, edge['via']]
|
|
239
|
+
return nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
target = @keys_by_identifier[identifier]&.first
|
|
204
243
|
return nil if target == source_id || !emitted.include?(target)
|
|
205
244
|
|
|
206
245
|
{ target: target, via: edge['via'] }
|
|
@@ -263,11 +302,22 @@ module Woods
|
|
|
263
302
|
# ── Pass 3b: machine sidecar ─────────────────────────────────────
|
|
264
303
|
|
|
265
304
|
def write_sidecar(emitted, mapper, graph, analysis, written)
|
|
266
|
-
notes =
|
|
267
|
-
|
|
268
|
-
|
|
305
|
+
notes = {}
|
|
306
|
+
variants = []
|
|
307
|
+
emitted.each do |key|
|
|
308
|
+
id, type = key
|
|
309
|
+
info = { 'path' => mapper.path_for(key), 'type' => type }
|
|
310
|
+
info['pagerank'] = @pagerank[id].round(6) if @pagerank[id] && !@ambiguous.include?(id)
|
|
311
|
+
if @primary.dig(id, 'type') == type
|
|
312
|
+
notes[id] = info
|
|
313
|
+
else
|
|
314
|
+
variants << { 'identifier' => id }.merge(info)
|
|
315
|
+
end
|
|
269
316
|
end
|
|
270
|
-
|
|
317
|
+
paths = mapper.paths_to_ids.transform_values(&:first)
|
|
318
|
+
manifest = { 'schema_version' => @ambiguous.empty? ? 1 : 2,
|
|
319
|
+
'notes' => sort_hash(notes), 'paths' => sort_hash(paths) }
|
|
320
|
+
manifest['variants'] = variants if manifest['schema_version'] == 2
|
|
271
321
|
write_json(written, "#{SIDECAR_DIR}/manifest.json", manifest)
|
|
272
322
|
write_json(written, "#{SIDECAR_DIR}/dependency_graph.json", graph)
|
|
273
323
|
write_json(written, "#{SIDECAR_DIR}/graph_analysis.json", analysis) if analysis
|
|
@@ -282,6 +332,8 @@ module Woods
|
|
|
282
332
|
safe_write(@vault.join('.obsidian', 'graph.json'), VaultAssets.graph_json(present_types(emitted)))
|
|
283
333
|
safe_write(@vault.join('Units.base'), VaultAssets.units_base)
|
|
284
334
|
end
|
|
335
|
+
return if @vault.join(SENTINEL).exist?
|
|
336
|
+
|
|
285
337
|
safe_write(@vault.join(SENTINEL),
|
|
286
338
|
"woods-managed vault — regenerated by `rake woods:obsidian`. Safe to delete.\n")
|
|
287
339
|
end
|
|
@@ -410,7 +462,11 @@ module Woods
|
|
|
410
462
|
def safe_write(path, content)
|
|
411
463
|
raise PathTraversalError, "refusing to write outside the vault: #{path}" unless safe_inside_vault?(path)
|
|
412
464
|
|
|
413
|
-
|
|
465
|
+
if @planned_writes
|
|
466
|
+
@planned_writes[path] = content
|
|
467
|
+
else
|
|
468
|
+
AtomicFile.write(path, content)
|
|
469
|
+
end
|
|
414
470
|
end
|
|
415
471
|
|
|
416
472
|
# ── Ownership ────────────────────────────────────────────────────
|
|
@@ -85,14 +85,18 @@ module Woods
|
|
|
85
85
|
|
|
86
86
|
# Reset one supported operation or all supported operations atomically.
|
|
87
87
|
#
|
|
88
|
+
# An explicit :all reset also repairs corrupt state; scoped resets leave it
|
|
89
|
+
# untouched so unknown cooldowns are not silently discarded.
|
|
90
|
+
#
|
|
88
91
|
# @param operation [Symbol, String] `:extraction`, `:embedding`, or `:all`
|
|
89
|
-
# @return [Boolean] true when
|
|
92
|
+
# @return [Boolean] true when a cooldown was removed or corrupt state repaired
|
|
90
93
|
# @raise [ArgumentError] when the operation is unsupported
|
|
91
94
|
def reset!(operation)
|
|
92
95
|
operations = reset_operations(operation)
|
|
93
|
-
|
|
96
|
+
repair_corrupt = operation.to_s == 'all'
|
|
97
|
+
return false unless requested_state_present?(operations, repair_corrupt: repair_corrupt)
|
|
94
98
|
|
|
95
|
-
with_existing_locked_state do |state|
|
|
99
|
+
with_existing_locked_state(repair_corrupt: repair_corrupt) do |state|
|
|
96
100
|
changed = false
|
|
97
101
|
operations.each do |key|
|
|
98
102
|
next unless state.key?(key)
|
|
@@ -135,28 +139,29 @@ module Woods
|
|
|
135
139
|
end
|
|
136
140
|
end
|
|
137
141
|
|
|
138
|
-
def parse_state(content)
|
|
139
|
-
state =
|
|
140
|
-
state.is_a?(Hash) ? state :
|
|
142
|
+
def parse_state(content, fallback: {})
|
|
143
|
+
state = JSON.parse(content)
|
|
144
|
+
state.is_a?(Hash) ? state : fallback
|
|
141
145
|
rescue JSON::ParserError
|
|
142
|
-
|
|
146
|
+
fallback
|
|
143
147
|
end
|
|
144
148
|
|
|
145
|
-
def requested_state_present?(operations)
|
|
149
|
+
def requested_state_present?(operations, repair_corrupt:)
|
|
146
150
|
File.open(@state_path, File::RDONLY) do |file|
|
|
147
151
|
file.flock(File::LOCK_SH)
|
|
148
|
-
state = parse_state(file.read)
|
|
149
|
-
operations.any? { |key| state.key?(key) }
|
|
152
|
+
state = parse_state(file.read, fallback: nil)
|
|
153
|
+
state ? operations.any? { |key| state.key?(key) } : repair_corrupt
|
|
150
154
|
end
|
|
151
155
|
rescue Errno::ENOENT
|
|
152
156
|
false
|
|
153
157
|
end
|
|
154
158
|
|
|
155
|
-
def with_existing_locked_state
|
|
159
|
+
def with_existing_locked_state(repair_corrupt:)
|
|
156
160
|
File.open(@state_path, File::RDWR) do |file|
|
|
157
161
|
file.flock(File::LOCK_EX)
|
|
158
|
-
|
|
159
|
-
|
|
162
|
+
parsed = parse_state(file.read, fallback: nil)
|
|
163
|
+
state = parsed || {}
|
|
164
|
+
changed = yield(state) || (repair_corrupt && parsed.nil?)
|
|
160
165
|
if changed
|
|
161
166
|
file.rewind
|
|
162
167
|
file.write(JSON.generate(state))
|
|
@@ -94,6 +94,11 @@ module Woods
|
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
class << self
|
|
97
|
+
# Runtime-discovered classes have no per-file extractor method.
|
|
98
|
+
def runtime_rules
|
|
99
|
+
@runtime_rules ||= [Rule.new(dirs: %w[app], extensions: %w[.rb])].freeze
|
|
100
|
+
end
|
|
101
|
+
|
|
97
102
|
# Rules for extractors with a per-file entry point.
|
|
98
103
|
#
|
|
99
104
|
# @return [Array<Rule>]
|
|
@@ -147,6 +152,7 @@ module Woods
|
|
|
147
152
|
# ConcernExtractor globs app/**/concerns, not just the two canonical
|
|
148
153
|
# directories — match any .rb under app/ inside a concerns/ segment.
|
|
149
154
|
file_rule(:concerns, :extract_concern_file, %w[app], require_segment: '/concerns/'),
|
|
155
|
+
file_rule(:concerns, :extract_model_mixin_file, %w[app lib], exclude: %w[/concerns/]),
|
|
150
156
|
# GraphQL types sit outside FILE_BASED (they share one extractor
|
|
151
157
|
# method across four unit types via GRAPHQL_TYPES), which is exactly
|
|
152
158
|
# why the FILE_BASED-driven coverage guard never noticed they had no
|
|
@@ -258,7 +264,7 @@ module Woods
|
|
|
258
264
|
# @param relative_path [String] Rails.root-relative path
|
|
259
265
|
# @return [Boolean]
|
|
260
266
|
def relevant?(relative_path)
|
|
261
|
-
return true if
|
|
267
|
+
return true if self.class.runtime_rules.any? { |rule| rule.matches?(relative_path) }
|
|
262
268
|
|
|
263
269
|
file_rules_for(relative_path).any? || whole_app_keys_for(relative_path).any?
|
|
264
270
|
end
|
data/lib/woods/payload_store.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require 'pathname'
|
|
4
4
|
require 'fileutils'
|
|
5
|
-
require 'find'
|
|
6
5
|
|
|
7
6
|
module Woods
|
|
8
7
|
# Writer-side owner of the per-generation payload directories.
|
|
@@ -95,15 +94,19 @@ module Woods
|
|
|
95
94
|
# @param target [Pathname, String] a directory returned by {#create}
|
|
96
95
|
# @return [void]
|
|
97
96
|
def clone(source, target)
|
|
98
|
-
from =
|
|
99
|
-
return unless
|
|
100
|
-
|
|
101
|
-
to =
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
97
|
+
from = File.expand_path(source.to_s)
|
|
98
|
+
return unless File.directory?(from)
|
|
99
|
+
|
|
100
|
+
to = target.to_s
|
|
101
|
+
FileUtils.mkdir_p(to)
|
|
102
|
+
pending = [[from, to]]
|
|
103
|
+
until pending.empty?
|
|
104
|
+
source_dir, target_dir = pending.pop
|
|
105
|
+
Dir.each_child(source_dir) do |name|
|
|
106
|
+
entry = File.join(source_dir, name)
|
|
107
|
+
destination = File.join(target_dir, name)
|
|
108
|
+
replicate(entry, destination, pending)
|
|
109
|
+
end
|
|
107
110
|
end
|
|
108
111
|
end
|
|
109
112
|
|
|
@@ -168,7 +171,7 @@ module Woods
|
|
|
168
171
|
# @param destination [Pathname, String] where it should land
|
|
169
172
|
# @return [void]
|
|
170
173
|
def link_or_copy(source, destination)
|
|
171
|
-
|
|
174
|
+
File.link(source.to_s, destination.to_s)
|
|
172
175
|
rescue Errno::EXDEV, Errno::EPERM, Errno::EMLINK, NotImplementedError
|
|
173
176
|
FileUtils.cp(source.to_s, destination.to_s)
|
|
174
177
|
end
|
|
@@ -211,11 +214,22 @@ module Woods
|
|
|
211
214
|
end
|
|
212
215
|
end
|
|
213
216
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
+
# Classify once: Find.find followed by File.directory? queried metadata
|
|
218
|
+
# twice per file, costly on FUSE/virtiofs mounts. Parents are created before
|
|
219
|
+
# their children, so regular files need no extra parent-directory checks.
|
|
220
|
+
# Preserve the old walk's symlink behavior: a directory symlink becomes an
|
|
221
|
+
# empty directory, never a traversal into another tree or generation.
|
|
222
|
+
#
|
|
223
|
+
# @param entry [String] source entry
|
|
224
|
+
# @param destination [String] where it belongs in the target
|
|
225
|
+
# @param pending [Array] directories still to visit
|
|
226
|
+
# @return [void]
|
|
227
|
+
def replicate(entry, destination, pending)
|
|
228
|
+
stat = File.lstat(entry)
|
|
229
|
+
if stat.directory? || (stat.symlink? && File.directory?(entry))
|
|
230
|
+
FileUtils.mkdir_p(destination)
|
|
231
|
+
pending << [entry, destination] if stat.directory?
|
|
217
232
|
else
|
|
218
|
-
FileUtils.mkdir_p(destination.dirname.to_s)
|
|
219
233
|
link_or_copy(entry, destination)
|
|
220
234
|
end
|
|
221
235
|
end
|
data/lib/woods/railtie.rb
CHANGED
|
@@ -14,8 +14,8 @@ module Woods
|
|
|
14
14
|
# flags and tokens set in `config/initializers/woods.rb` (the
|
|
15
15
|
# generator-created location) do not exist yet when middleware arguments
|
|
16
16
|
# are captured. Both guards are scoped to `console_mcp_path` — requests
|
|
17
|
-
# outside it pass through untouched — and while `console_mcp_enabled`
|
|
18
|
-
# false the whole stack passes through, so an unconfigured host app is
|
|
17
|
+
# outside it pass through untouched — and while `console_mcp_enabled` or
|
|
18
|
+
# `console_mcp_http_enabled` is false the whole stack passes through, so an unconfigured host app is
|
|
19
19
|
# completely unaffected. Boot-time validation of the settled configuration
|
|
20
20
|
# happens in `after_initialize` via {Woods::RailtieSupport}.
|
|
21
21
|
#
|
|
@@ -84,7 +84,7 @@ module Woods
|
|
|
84
84
|
# BearerAuth next — requires `Authorization: Bearer <token>` at the
|
|
85
85
|
# console path, failing closed (401) while no usable token is
|
|
86
86
|
# configured. Both are scoped to the console path and inert while
|
|
87
|
-
# console_mcp_enabled is false.
|
|
87
|
+
# either console_mcp_enabled or console_mcp_http_enabled is false.
|
|
88
88
|
app.middleware.use(
|
|
89
89
|
Woods::MCP::OriginGuard,
|
|
90
90
|
allowed_origins: Woods::RailtieSupport.console_allowed_origins_proc,
|
|
@@ -15,11 +15,9 @@ module Woods
|
|
|
15
15
|
# exercise the request-time procs and boot-time verification without
|
|
16
16
|
# loading Rails::Railtie.
|
|
17
17
|
module RailtieSupport
|
|
18
|
-
# Boot-time message
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# path); the stdio server never sees a bearer header, so for it a missing
|
|
22
|
-
# token is a production-boot failure, not a request failure.
|
|
18
|
+
# Boot-time message when HTTP Console is enabled without a token.
|
|
19
|
+
# Production refuses to boot; other environments warn. Explicitly
|
|
20
|
+
# disabling HTTP leaves stdio available without HTTP token validation.
|
|
23
21
|
MISSING_TOKEN_MESSAGE =
|
|
24
22
|
'[Woods Console] console_mcp_token is not set — Console MCP is a high-privilege ' \
|
|
25
23
|
'endpoint that runs SQL and model introspection against the live database. ' \
|
|
@@ -37,7 +35,8 @@ module Woods
|
|
|
37
35
|
'HTTP Console MCP requests will be refused (401) until one is set. The stdio ' \
|
|
38
36
|
'transport (rake woods:console) does not transmit or use a bearer token, so a ' \
|
|
39
37
|
'stdio-only setup still works — set the token before exposing the HTTP endpoint. ' \
|
|
40
|
-
'
|
|
38
|
+
'Set console_mcp_http_enabled = false for stdio-only use. ' \
|
|
39
|
+
'Production boot will raise while HTTP Console MCP is enabled without a token.'
|
|
41
40
|
|
|
42
41
|
class << self
|
|
43
42
|
# @return [String, nil] path the console stack was mounted at (captured
|
|
@@ -53,12 +52,13 @@ module Woods
|
|
|
53
52
|
|
|
54
53
|
# Request-time predicate handed to the console guards and consulted by
|
|
55
54
|
# {Woods::Console::RackMiddleware}. Reads the live configuration on
|
|
56
|
-
# every call
|
|
55
|
+
# every call. Both the Console master switch and HTTP switch must be on,
|
|
56
|
+
# so a flag set after the middleware was inserted (e.g. in
|
|
57
57
|
# config/initializers) still takes effect.
|
|
58
58
|
#
|
|
59
59
|
# @return [Proc] arity-0 proc returning the current enabled flag
|
|
60
60
|
def console_enabled_proc
|
|
61
|
-
-> { config.console_mcp_enabled }
|
|
61
|
+
-> { config.console_mcp_enabled && config.console_mcp_http_enabled }
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
# Deferred bearer token for {Woods::MCP::BearerAuth}, resolved on each
|
|
@@ -95,12 +95,12 @@ module Woods
|
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
# Boot-time console validation, run from `after_initialize` once the
|
|
98
|
-
# final configuration is known. With
|
|
98
|
+
# final configuration is known. With HTTP Console enabled and no usable
|
|
99
99
|
# token, every HTTP request at the console path will be refused with
|
|
100
100
|
# 401 — production refuses to boot instead (matching the pre-#183
|
|
101
101
|
# fail-closed posture), other environments warn loudly. The stdio
|
|
102
|
-
# server carries no bearer check
|
|
103
|
-
#
|
|
102
|
+
# server carries no bearer check; explicit stdio-only configuration
|
|
103
|
+
# skips token validation entirely. A token shorter than
|
|
104
104
|
# {Woods::MCP::BearerAuth::MIN_TOKEN_LENGTH} is a misconfiguration and
|
|
105
105
|
# raises in every environment (as the eager BearerAuth constructor
|
|
106
106
|
# used to). Also warns when `console_mcp_path` changed after the stack
|
|
@@ -112,7 +112,7 @@ module Woods
|
|
|
112
112
|
# or a too-short token anywhere
|
|
113
113
|
def verify_console_configuration!(production:)
|
|
114
114
|
warn_late_console_path(config)
|
|
115
|
-
return unless config.console_mcp_enabled
|
|
115
|
+
return unless config.console_mcp_enabled && config.console_mcp_http_enabled
|
|
116
116
|
|
|
117
117
|
require 'woods/mcp/bearer_auth'
|
|
118
118
|
token = config.console_mcp_token.to_s
|