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.
Files changed (221) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +400 -1
  3. data/CONTRIBUTING.md +224 -9
  4. data/README.md +7 -3
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +83 -4
  7. data/docs/AGENT_SETUP.md +82 -1
  8. data/docs/BACKEND_MATRIX.md +20 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +233 -13
  11. data/docs/CONSOLE_MCP_SETUP.md +35 -5
  12. data/docs/DOCKER_SETUP.md +21 -2
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +36 -5
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +158 -2
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +15 -7
  20. data/docs/MCP_SERVERS.md +221 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +33 -18
  22. data/docs/NOTION_INTEGRATION.md +13 -0
  23. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  24. data/docs/PUBLISHED_INDEX.md +71 -0
  25. data/docs/README.md +7 -0
  26. data/docs/RETRIEVAL_GUIDE.md +253 -11
  27. data/docs/RUNTIME_TRACING.md +71 -0
  28. data/docs/SOURCE_FRESHNESS.md +143 -0
  29. data/docs/TROUBLESHOOTING.md +117 -5
  30. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  31. data/docs/UPGRADING_TO_2.md +44 -22
  32. data/docs/WATCH_DAEMON.md +259 -59
  33. data/exe/woods-agent-config +6 -0
  34. data/exe/woods-extract +5 -0
  35. data/exe/woods-hook-context +6 -0
  36. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  37. data/lib/tasks/woods.rake +47 -397
  38. data/lib/woods/agent_configuration/applier.rb +133 -0
  39. data/lib/woods/agent_configuration/cli.rb +101 -0
  40. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  41. data/lib/woods/agent_configuration/document.rb +105 -0
  42. data/lib/woods/agent_configuration/error.rb +7 -0
  43. data/lib/woods/agent_configuration/launcher.rb +75 -0
  44. data/lib/woods/agent_configuration/layout.rb +59 -0
  45. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  46. data/lib/woods/agent_configuration/plan.rb +98 -0
  47. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  48. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  49. data/lib/woods/agent_configuration/planner.rb +63 -0
  50. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  51. data/lib/woods/agent_configuration/preflight.rb +100 -0
  52. data/lib/woods/agent_configuration/recovery.rb +49 -0
  53. data/lib/woods/ast/node.rb +2 -0
  54. data/lib/woods/ast/parser.rb +38 -5
  55. data/lib/woods/atomic_file.rb +133 -3
  56. data/lib/woods/builder.rb +21 -5
  57. data/lib/woods/cache/cache_middleware.rb +28 -7
  58. data/lib/woods/cache/cache_store.rb +4 -5
  59. data/lib/woods/change_set.rb +5 -4
  60. data/lib/woods/console/credential_index.rb +20 -2
  61. data/lib/woods/console/credential_scanner.rb +14 -14
  62. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  63. data/lib/woods/console/embedded_executor.rb +1 -1
  64. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  65. data/lib/woods/console/rack_middleware.rb +22 -13
  66. data/lib/woods/console/server.rb +18 -16
  67. data/lib/woods/dependency_graph.rb +65 -13
  68. data/lib/woods/embedding/corpus.rb +94 -0
  69. data/lib/woods/embedding/indexer.rb +90 -46
  70. data/lib/woods/embedding/openai.rb +17 -6
  71. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  72. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  73. data/lib/woods/export/typed_reader.rb +56 -0
  74. data/lib/woods/extractor.rb +557 -228
  75. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  76. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  77. data/lib/woods/extractors/caching_extractor.rb +3 -1
  78. data/lib/woods/extractors/concern_extractor.rb +64 -6
  79. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  80. data/lib/woods/extractors/controller_extractor.rb +13 -4
  81. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  82. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  83. data/lib/woods/extractors/engine_extractor.rb +3 -1
  84. data/lib/woods/extractors/event_extractor.rb +4 -2
  85. data/lib/woods/extractors/factory_extractor.rb +3 -1
  86. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  87. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  88. data/lib/woods/extractors/job_extractor.rb +6 -19
  89. data/lib/woods/extractors/lib_extractor.rb +3 -1
  90. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  91. data/lib/woods/extractors/manager_extractor.rb +3 -1
  92. data/lib/woods/extractors/method_parameters.rb +53 -0
  93. data/lib/woods/extractors/middleware_argument.rb +65 -0
  94. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  95. data/lib/woods/extractors/migration_extractor.rb +3 -1
  96. data/lib/woods/extractors/model_extractor.rb +39 -33
  97. data/lib/woods/extractors/package_extractor.rb +24 -4
  98. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  99. data/lib/woods/extractors/policy_extractor.rb +3 -1
  100. data/lib/woods/extractors/poro_extractor.rb +3 -1
  101. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  102. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  103. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  104. data/lib/woods/extractors/route_extractor.rb +3 -1
  105. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  106. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  107. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  108. data/lib/woods/extractors/service_extractor.rb +3 -1
  109. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  110. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  111. data/lib/woods/extractors/source_nesting.rb +1 -1
  112. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  113. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  114. data/lib/woods/extractors/validator_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  116. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  117. data/lib/woods/flow_assembler.rb +87 -8
  118. data/lib/woods/flow_precomputer.rb +44 -7
  119. data/lib/woods/gem_mapper.rb +2 -0
  120. data/lib/woods/git_history.rb +116 -0
  121. data/lib/woods/graph_analyzer.rb +195 -63
  122. data/lib/woods/hooks/context_cli.rb +54 -0
  123. data/lib/woods/hooks/context_event.rb +88 -0
  124. data/lib/woods/hooks/context_hint.rb +73 -0
  125. data/lib/woods/hooks/context_impact.rb +77 -0
  126. data/lib/woods/hooks/context_output.rb +47 -0
  127. data/lib/woods/hooks/context_state.rb +102 -0
  128. data/lib/woods/hooks/refresh.rb +79 -0
  129. data/lib/woods/hooks/rule_projection.rb +78 -0
  130. data/lib/woods/input_rules.rb +19 -0
  131. data/lib/woods/mcp/bearer_auth.rb +20 -12
  132. data/lib/woods/mcp/bootstrapper.rb +62 -0
  133. data/lib/woods/mcp/index_reader.rb +323 -160
  134. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  135. data/lib/woods/mcp/origin_guard.rb +17 -9
  136. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  137. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  138. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  139. data/lib/woods/mcp/search_results.rb +74 -0
  140. data/lib/woods/mcp/server.rb +158 -37
  141. data/lib/woods/mcp/tool_contract.rb +2 -0
  142. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  143. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  144. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  145. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  146. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  147. data/lib/woods/notion/exporter.rb +56 -17
  148. data/lib/woods/obsidian/destination_plan.rb +98 -0
  149. data/lib/woods/obsidian/name_mapper.rb +19 -3
  150. data/lib/woods/obsidian/note_builder.rb +19 -10
  151. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  152. data/lib/woods/operator/pipeline_guard.rb +18 -13
  153. data/lib/woods/path_dispatcher.rb +7 -1
  154. data/lib/woods/payload_store.rb +29 -15
  155. data/lib/woods/railtie.rb +3 -3
  156. data/lib/woods/railtie_support.rb +12 -12
  157. data/lib/woods/rake_helpers.rb +392 -0
  158. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  159. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  160. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  161. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  162. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  163. data/lib/woods/resilience/index_validator.rb +112 -23
  164. data/lib/woods/retrieval/context_assembler.rb +50 -15
  165. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  166. data/lib/woods/retrieval/lexical_index.rb +119 -0
  167. data/lib/woods/retrieval/ranker.rb +4 -2
  168. data/lib/woods/retrieval/scope.rb +108 -0
  169. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  170. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  171. data/lib/woods/retrieval/search_executor.rb +86 -27
  172. data/lib/woods/retrieval/source_evidence.rb +200 -0
  173. data/lib/woods/retriever.rb +98 -22
  174. data/lib/woods/ruby_analyzer/trace_enricher.rb +80 -38
  175. data/lib/woods/session_tracer/middleware.rb +10 -12
  176. data/lib/woods/session_tracer/redis_store.rb +22 -6
  177. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  178. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  179. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  180. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  181. data/lib/woods/source_inputs/handoff.rb +102 -0
  182. data/lib/woods/source_inputs/launcher.rb +157 -0
  183. data/lib/woods/source_inputs/manifest.rb +124 -0
  184. data/lib/woods/source_inputs/private_key.rb +55 -0
  185. data/lib/woods/source_inputs/scanner.rb +171 -0
  186. data/lib/woods/source_inputs/scopes.rb +71 -0
  187. data/lib/woods/source_inputs/session.rb +214 -0
  188. data/lib/woods/source_inputs/status.rb +84 -0
  189. data/lib/woods/source_inputs/verifier.rb +107 -0
  190. data/lib/woods/storage/metadata_store.rb +25 -25
  191. data/lib/woods/storage/pgvector.rb +29 -8
  192. data/lib/woods/storage/qdrant.rb +17 -7
  193. data/lib/woods/storage/vector_store.rb +18 -6
  194. data/lib/woods/tasks.rb +3 -2
  195. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  196. data/lib/woods/unblocked/exporter.rb +59 -70
  197. data/lib/woods/version.rb +1 -1
  198. data/lib/woods/watch/boot_snapshot.rb +52 -0
  199. data/lib/woods/watch/daemon.rb +136 -28
  200. data/lib/woods/watch/listen_watcher.rb +4 -0
  201. data/lib/woods/watch/polling_watcher.rb +5 -1
  202. data/lib/woods/watch/status.rb +20 -15
  203. data/lib/woods/watch/tree_scan.rb +21 -13
  204. data/lib/woods/watch/watcher.rb +4 -1
  205. data/lib/woods.rb +135 -11
  206. data/plugin/.claude-plugin/plugin.json +1 -1
  207. data/plugin/hooks/adapters/normalize.jq +15 -0
  208. data/plugin/hooks/adapters/normalize.rb +63 -0
  209. data/plugin/hooks/hooks.json +20 -0
  210. data/plugin/hooks/woods-context.sh +50 -0
  211. data/plugin/hooks/woods-input-rules.sh +159 -0
  212. data/plugin/hooks/woods-opencode.mjs +65 -0
  213. data/plugin/hooks/woods-post-edit.sh +2 -225
  214. data/plugin/hooks/woods-refresh.sh +260 -0
  215. data/plugin/hooks/woods-session-start.sh +47 -55
  216. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  217. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  218. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  219. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  220. data/plugin/skills/woods-setup/SKILL.md +107 -6
  221. 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
- @nodes = graph['nodes'] || {}
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>, Integer)] [sorted emitted ids, skipped count]
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
- next false if !@include_framework && FRAMEWORK_TYPES.include?(type)
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
- @reader.find_unit(id)
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
- end
187
-
188
- # The single edge source: per-id forward (depends_on) and reverse (used_by)
189
- # edges from the graph, filtered to the emitted set, self-edges dropped.
190
- def build_edge_map(emitted, graph)
191
- edges = graph['edges'] || {}
192
- reverse = graph['reverse'] || {}
193
- emitted.each_with_object({}) do |id, map|
194
- deps = Array(edges[id]).filter_map { |edge| forward_edge(edge, id, emitted) }
195
- .uniq { |edge| [edge[:target], edge[:via]] }
196
- used = Array(reverse[id]).select { |sid| sid != id && emitted.include?(sid) }.uniq
197
- map[id] = { depends_on: deps, used_by: used }
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
- target = edge['target']
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 = emitted.to_h do |id|
267
- [id, { 'path' => mapper.path_for(id), 'type' => @nodes[id]['type'],
268
- 'pagerank' => @pagerank[id]&.round(6) }.compact]
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
- manifest = { 'schema_version' => 1, 'notes' => sort_hash(notes), 'paths' => sort_hash(mapper.paths_to_ids) }
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
- AtomicFile.write(path, content)
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 at least one cooldown was removed
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
- return false unless requested_state_present?(operations)
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 = content.empty? ? {} : JSON.parse(content)
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
- state = parse_state(file.read)
159
- changed = yield(state)
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 relative_path.start_with?('app/') && relative_path.end_with?('.rb')
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
@@ -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 = Pathname.new(source.to_s)
99
- return unless from.directory?
100
-
101
- to = Pathname.new(target.to_s)
102
- from.find do |entry|
103
- relative = entry.relative_path_from(from)
104
- next if relative.to_s == '.'
105
-
106
- replicate(entry, to.join(relative))
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
- FileUtils.ln(source.to_s, destination.to_s)
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
- def replicate(entry, destination)
215
- if entry.directory?
216
- FileUtils.mkdir_p(destination.to_s)
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` is
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 for a console enabled without a token. Raised in
19
- # production; warned everywhere else. Only the HTTP transport enforces
20
- # the token ({Woods::MCP::BearerAuth} fails closed with 401 at the console
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
- 'Production boot will raise while Console MCP is enabled without a token.'
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, so a flag set after the middleware was inserted (e.g. in
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 the console enabled and no usable
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, so the warning says which transport
103
- # it is about rather than claiming every request fails. A token shorter than
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