woods 1.6.2 → 2.0.0.beta1

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 (278) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1889 -11
  3. data/CONTRIBUTING.md +195 -129
  4. data/README.md +162 -520
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +620 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +415 -0
  19. data/docs/INTERNALS.md +415 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +197 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +77 -10
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +40 -1
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/credential_index.rb +2 -20
  64. data/lib/woods/console/credential_scanner.rb +14 -14
  65. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  66. data/lib/woods/console/embedded_executor.rb +806 -149
  67. data/lib/woods/console/eval_guard.rb +27 -20
  68. data/lib/woods/console/input_contract.rb +78 -0
  69. data/lib/woods/console/model_validator.rb +29 -1
  70. data/lib/woods/console/rack_middleware.rb +63 -43
  71. data/lib/woods/console/redactor.rb +26 -8
  72. data/lib/woods/console/safe_context.rb +58 -10
  73. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  74. data/lib/woods/console/server.rb +135 -265
  75. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  76. data/lib/woods/console/sql_table_scanner.rb +82 -22
  77. data/lib/woods/console/sql_validator.rb +459 -29
  78. data/lib/woods/console/table_gate.rb +2 -2
  79. data/lib/woods/console/tool_specs.rb +462 -88
  80. data/lib/woods/console/tools/tier1.rb +0 -3
  81. data/lib/woods/console/tools/tier4.rb +17 -7
  82. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  83. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  84. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  85. data/lib/woods/db/migrator.rb +3 -9
  86. data/lib/woods/db/schema_version.rb +47 -2
  87. data/lib/woods/dependency_graph.rb +898 -64
  88. data/lib/woods/embedding/fake.rb +138 -0
  89. data/lib/woods/embedding/indexer.rb +832 -40
  90. data/lib/woods/embedding/openai.rb +77 -19
  91. data/lib/woods/embedding/provider.rb +189 -11
  92. data/lib/woods/embedding/text_preparer.rb +1 -1
  93. data/lib/woods/embedding/token_counter.rb +0 -7
  94. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  95. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  96. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  97. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  98. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  99. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  100. data/lib/woods/evaluation/ablation_task.rb +66 -0
  101. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  102. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  103. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  104. data/lib/woods/evaluation/baseline.rb +60 -0
  105. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  106. data/lib/woods/evaluation/evaluator.rb +41 -8
  107. data/lib/woods/evaluation/query_set.rb +79 -13
  108. data/lib/woods/evaluation/report_generator.rb +20 -1
  109. data/lib/woods/export/unit_facts.rb +0 -11
  110. data/lib/woods/extracted_unit.rb +22 -63
  111. data/lib/woods/extractor.rb +2503 -192
  112. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  113. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  114. data/lib/woods/extractors/caching_extractor.rb +46 -12
  115. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  116. data/lib/woods/extractors/component_discovery.rb +123 -0
  117. data/lib/woods/extractors/concern_extractor.rb +17 -3
  118. data/lib/woods/extractors/controller_extractor.rb +389 -29
  119. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  120. data/lib/woods/extractors/engine_extractor.rb +53 -8
  121. data/lib/woods/extractors/event_extractor.rb +55 -4
  122. data/lib/woods/extractors/factory_extractor.rb +49 -11
  123. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  124. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  125. data/lib/woods/extractors/job_extractor.rb +51 -21
  126. data/lib/woods/extractors/lib_extractor.rb +23 -17
  127. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  128. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  129. data/lib/woods/extractors/manager_extractor.rb +19 -2
  130. data/lib/woods/extractors/migration_extractor.rb +22 -11
  131. data/lib/woods/extractors/model_extractor.rb +292 -57
  132. data/lib/woods/extractors/package_extractor.rb +154 -0
  133. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  134. data/lib/woods/extractors/policy_extractor.rb +6 -5
  135. data/lib/woods/extractors/poro_extractor.rb +13 -14
  136. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  137. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  138. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  139. data/lib/woods/extractors/reference_patterns.rb +38 -0
  140. data/lib/woods/extractors/route_extractor.rb +58 -2
  141. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  142. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  143. data/lib/woods/extractors/service_extractor.rb +11 -1
  144. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  145. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  146. data/lib/woods/extractors/source_nesting.rb +560 -0
  147. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  148. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  149. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  150. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  151. data/lib/woods/feedback/gap_detector.rb +9 -3
  152. data/lib/woods/feedback/store.rb +7 -1
  153. data/lib/woods/filename_utils.rb +29 -1
  154. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  155. data/lib/woods/flow_assembler.rb +63 -21
  156. data/lib/woods/flow_document.rb +1 -0
  157. data/lib/woods/flow_precomputer.rb +138 -22
  158. data/lib/woods/gem_mapper.rb +285 -0
  159. data/lib/woods/generation.rb +185 -0
  160. data/lib/woods/git_command.rb +38 -0
  161. data/lib/woods/git_provenance.rb +16 -2
  162. data/lib/woods/graph_analyzer.rb +408 -34
  163. data/lib/woods/index_artifact.rb +93 -23
  164. data/lib/woods/mcp/bearer_auth.rb +102 -13
  165. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  166. data/lib/woods/mcp/bootstrapper.rb +582 -77
  167. data/lib/woods/mcp/config_resolver.rb +66 -6
  168. data/lib/woods/mcp/errors.rb +60 -0
  169. data/lib/woods/mcp/index_reader.rb +836 -117
  170. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  171. data/lib/woods/mcp/origin_guard.rb +66 -7
  172. data/lib/woods/mcp/protocol_policy.rb +98 -0
  173. data/lib/woods/mcp/provider_probe.rb +45 -6
  174. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  175. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  176. data/lib/woods/mcp/server.rb +907 -154
  177. data/lib/woods/mcp/tasks/extension.rb +196 -0
  178. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  179. data/lib/woods/mcp/tasks/store.rb +518 -0
  180. data/lib/woods/mcp/tool_contract.rb +171 -0
  181. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  182. data/lib/woods/mcp/version_aware_tool_dispatch.rb +3 -9
  183. data/lib/woods/model_name_cache.rb +19 -1
  184. data/lib/woods/notion/client.rb +132 -36
  185. data/lib/woods/notion/exporter.rb +456 -61
  186. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  187. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  188. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  189. data/lib/woods/notion/mappers/shared.rb +45 -3
  190. data/lib/woods/notion/sync_manifest.rb +258 -0
  191. data/lib/woods/obsidian/errors.rb +6 -0
  192. data/lib/woods/obsidian/name_mapper.rb +40 -24
  193. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  194. data/lib/woods/operator/pipeline_guard.rb +118 -21
  195. data/lib/woods/operator/status_reporter.rb +20 -3
  196. data/lib/woods/path_dispatcher.rb +276 -0
  197. data/lib/woods/payload_store.rb +223 -0
  198. data/lib/woods/published_index/edge_shaper.rb +61 -0
  199. data/lib/woods/published_index/generation_catalog.rb +72 -0
  200. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  201. data/lib/woods/published_index.rb +287 -0
  202. data/lib/woods/railtie.rb +69 -30
  203. data/lib/woods/railtie_support.rb +167 -0
  204. data/lib/woods/release.rb +12 -0
  205. data/lib/woods/reload_policy.rb +206 -0
  206. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  207. data/lib/woods/resilience/index_validator.rb +296 -10
  208. data/lib/woods/resilience/retryable_provider.rb +71 -6
  209. data/lib/woods/resolved_config.rb +55 -11
  210. data/lib/woods/retrieval/context_assembler.rb +132 -40
  211. data/lib/woods/retrieval/query_classifier.rb +25 -6
  212. data/lib/woods/retrieval/ranker.rb +193 -28
  213. data/lib/woods/retrieval/search_executor.rb +206 -39
  214. data/lib/woods/retriever.rb +317 -71
  215. data/lib/woods/retry_after.rb +22 -2
  216. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  217. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  218. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  219. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  220. data/lib/woods/ruby_analyzer.rb +21 -5
  221. data/lib/woods/session_tracer/file_store.rb +138 -19
  222. data/lib/woods/session_tracer/redis_store.rb +122 -12
  223. data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
  224. data/lib/woods/session_tracer/session_flow_document.rb +52 -6
  225. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  226. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  227. data/lib/woods/session_tracer/store.rb +14 -1
  228. data/lib/woods/storage/metadata_store.rb +230 -26
  229. data/lib/woods/storage/pgvector.rb +180 -22
  230. data/lib/woods/storage/qdrant.rb +367 -41
  231. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  232. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  233. data/lib/woods/storage/snapshotter.rb +23 -5
  234. data/lib/woods/storage/vector_store.rb +49 -8
  235. data/lib/woods/storage_identity.rb +28 -0
  236. data/lib/woods/tasks.rb +53 -2
  237. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  238. data/lib/woods/temporal/snapshot_store.rb +139 -42
  239. data/lib/woods/unblocked/client.rb +119 -17
  240. data/lib/woods/unblocked/document_builder.rb +34 -2
  241. data/lib/woods/unblocked/exporter.rb +63 -27
  242. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  243. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  244. data/lib/woods/update_check.rb +24 -1
  245. data/lib/woods/util/uuid5.rb +124 -0
  246. data/lib/woods/version.rb +1 -1
  247. data/lib/woods/watch/daemon.rb +1345 -0
  248. data/lib/woods/watch/listen_watcher.rb +81 -0
  249. data/lib/woods/watch/polling_watcher.rb +137 -0
  250. data/lib/woods/watch/status.rb +169 -0
  251. data/lib/woods/watch/tree_scan.rb +163 -0
  252. data/lib/woods/watch/watcher.rb +100 -0
  253. data/lib/woods.rb +53 -9
  254. data/plugin/.claude-plugin/plugin.json +18 -0
  255. data/plugin/hooks/hooks.json +29 -0
  256. data/plugin/hooks/woods-post-edit.sh +226 -0
  257. data/plugin/hooks/woods-session-start.sh +77 -0
  258. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  259. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  260. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  261. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  262. data/plugin/skills/woods-setup/SKILL.md +99 -0
  263. metadata +102 -26
  264. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  265. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  266. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  267. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  268. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  269. data/lib/woods/console/bridge.rb +0 -210
  270. data/lib/woods/console/credential_scanner_registry.rb +0 -36
  271. data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
  272. data/lib/woods/formatting/claude_adapter.rb +0 -98
  273. data/lib/woods/formatting/generic_adapter.rb +0 -56
  274. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  275. data/lib/woods/mcp/http_transport_options.rb +0 -24
  276. data/lib/woods/notion/mapper.rb +0 -40
  277. data/lib/woods/observability/health_check.rb +0 -79
  278. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -70,31 +70,53 @@ module Woods
70
70
  #
71
71
  # @return [Hash] { exported:, indexes:, swept:, skipped:, errors: }
72
72
  def export_all
73
- graph = @reader.raw_graph_data || {}
74
- @nodes = graph['nodes'] || {}
75
- @pagerank = graph['pagerank'] || {}
76
- analysis = safe_graph_analysis
77
- owned = vault_owned_at_start?
73
+ with_pinned_index do
74
+ graph = @reader.raw_graph_data || {}
75
+ @nodes = graph['nodes'] || {}
76
+ @pagerank = graph['pagerank'] || {}
77
+ analysis = safe_graph_analysis
78
+ owned = vault_owned_at_start?
79
+
80
+ emitted, skipped = partition_emitted
81
+ mapper = build_mapper(emitted)
82
+ edge_map = build_edge_map(emitted.to_set, graph)
83
+ builder = build_note_builder(mapper, analysis)
84
+
85
+ written = Set.new
86
+ errors = []
87
+ exported = write_notes(emitted, mapper, edge_map, builder, written, errors)
88
+ indexes = write_indexes(emitted, mapper, written)
89
+ write_sidecar(emitted, mapper, graph, analysis, written)
90
+ write_owned_assets(emitted) if owned
91
+
92
+ swept = owned && errors.empty? ? sweep(written) : 0
93
+ warn_foreign unless owned
94
+ { exported: exported, indexes: indexes, swept: swept, skipped: skipped, errors: cap_errors(errors) }
95
+ end
96
+ end
78
97
 
79
- emitted, skipped = partition_emitted
80
- mapper = build_mapper(emitted)
81
- edge_map = build_edge_map(emitted.to_set, graph)
82
- builder = build_note_builder(mapper, analysis)
98
+ private
83
99
 
84
- written = Set.new
85
- errors = []
86
- exported = write_notes(emitted, mapper, edge_map, builder, written, errors)
87
- indexes = write_indexes(emitted, mapper, written)
88
- write_sidecar(emitted, mapper, graph, analysis, written)
89
- write_owned_assets(emitted) if owned
100
+ # Run the export against one index generation.
101
+ #
102
+ # Every public IndexReader accessor self-refreshes when the published
103
+ # generation moves, and the reader assigns pinning responsibility to
104
+ # direct callers. Unpinned, `raw_graph_data` and the per-unit reads can
105
+ # straddle two generations (EXP-5) — which the "byte-identical across
106
+ # runs" contract cannot survive, and which computes the sweep set against
107
+ # a mixture.
108
+ #
109
+ # Guarded by +respond_to?+: injected readers (specs, embedders) need not
110
+ # implement pinning.
111
+ #
112
+ # @yield the export body
113
+ # @return [Object] the block's value
114
+ def with_pinned_index(&block)
115
+ return yield unless @reader.respond_to?(:with_pinned_generation)
90
116
 
91
- swept = owned && errors.empty? ? sweep(written) : 0
92
- warn_foreign unless owned
93
- { exported: exported, indexes: indexes, swept: swept, skipped: skipped, errors: cap_errors(errors) }
117
+ @reader.with_pinned_generation(&block)
94
118
  end
95
119
 
96
- private
97
-
98
120
  # ── Selection / mapping ──────────────────────────────────────────
99
121
 
100
122
  # The emitted set = graph nodes of an exportable type that have a loadable
@@ -145,7 +167,17 @@ module Woods
145
167
 
146
168
  def dir_for(type)
147
169
  require 'woods/mcp/index_reader'
148
- Woods::MCP::IndexReader::TYPE_TO_DIR[type] || "#{type}s"
170
+ Woods::MCP::IndexReader::TYPE_TO_DIR[type] || sanitize_dir_segment("#{type}s")
171
+ end
172
+
173
+ # Same character policy as NameMapper's basename sanitizer: an unknown
174
+ # graph node type reaches here verbatim (it isn't a fixed TYPE_TO_DIR
175
+ # value), so it gets the same treatment before being used as a path
176
+ # segment — otherwise a type of "../../escape" would flow straight into
177
+ # the vault-relative path NameMapper builds.
178
+ def sanitize_dir_segment(str)
179
+ out = str.to_s.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
180
+ out.empty? ? 'unit' : out
149
181
  end
150
182
 
151
183
  def build_note_builder(mapper, analysis)
@@ -187,7 +219,7 @@ module Woods
187
219
  next if note.nil?
188
220
 
189
221
  rel = mapper.path_for(id)
190
- AtomicFile.write(@vault.join(rel), note)
222
+ safe_write(@vault.join(rel), note)
191
223
  written << rel
192
224
  exported += 1
193
225
  rescue StandardError => e
@@ -245,13 +277,13 @@ module Woods
245
277
 
246
278
  def write_owned_assets(emitted)
247
279
  if can_write_obsidian_config?
248
- AtomicFile.write(@vault.join('.obsidian', 'app.json'), VaultAssets.app_json)
249
- AtomicFile.write(@vault.join('.obsidian', 'types.json'), VaultAssets.types_json)
250
- AtomicFile.write(@vault.join('.obsidian', 'graph.json'), VaultAssets.graph_json(present_types(emitted)))
251
- AtomicFile.write(@vault.join('Units.base'), VaultAssets.units_base)
280
+ safe_write(@vault.join('.obsidian', 'app.json'), VaultAssets.app_json)
281
+ safe_write(@vault.join('.obsidian', 'types.json'), VaultAssets.types_json)
282
+ safe_write(@vault.join('.obsidian', 'graph.json'), VaultAssets.graph_json(present_types(emitted)))
283
+ safe_write(@vault.join('Units.base'), VaultAssets.units_base)
252
284
  end
253
- AtomicFile.write(@vault.join(SENTINEL),
254
- "woods-managed vault — regenerated by `rake woods:obsidian`. Safe to delete.\n")
285
+ safe_write(@vault.join(SENTINEL),
286
+ "woods-managed vault — regenerated by `rake woods:obsidian`. Safe to delete.\n")
255
287
  end
256
288
 
257
289
  def can_write_obsidian_config?
@@ -284,12 +316,19 @@ module Woods
284
316
  end
285
317
  end
286
318
 
319
+ # Every managed note in the vault, vault-relative.
320
+ #
321
+ # `base:` keeps the vault's own path out of the pattern: "[", "]", "{",
322
+ # "}", "*" and "?" in a folder name (`my [work] vault` is an ordinary
323
+ # human folder name) are glob syntax, and interpolating the path matched
324
+ # nothing — the sweep saw zero managed notes and deleted nothing,
325
+ # silently, forever (EXP-6).
326
+ #
327
+ # @return [Array<String>] paths relative to the vault root
287
328
  def managed_notes
288
- Dir.glob(@vault.join('**', '*.md')).filter_map do |abs|
289
- next unless managed_marker?(abs)
329
+ return [] unless @vault.directory?
290
330
 
291
- Pathname.new(abs).relative_path_from(@vault).to_s
292
- end
331
+ Dir.glob(File.join('**', '*.md'), base: @vault.to_s).select { |rel| managed_marker?(@vault.join(rel)) }
293
332
  end
294
333
 
295
334
  # Only our notes carry this frontmatter flag. Reads just the frontmatter
@@ -339,13 +378,41 @@ module Woods
339
378
  end
340
379
 
341
380
  def safe_inside_vault?(path)
342
- root = @vault.realpath.to_s
343
- target = path.exist? ? path.realpath.to_s : path.expand_path.to_s
381
+ root = canonical(@vault)
382
+ target = canonical(path)
344
383
  target == root || target.start_with?(root + File::SEPARATOR)
345
384
  rescue StandardError
346
385
  false
347
386
  end
348
387
 
388
+ # realpath requires the target to exist, but a write target (and, on the
389
+ # very first write of a fresh export, the vault root itself) usually
390
+ # doesn't yet. Resolve symlinks on the nearest existing ancestor only,
391
+ # then reattach the not-yet-created suffix literally. Both root and
392
+ # target must go through this same resolution — comparing a realpath'd
393
+ # root against an expand_path'd target falsely disagrees the moment an
394
+ # ancestor (e.g. macOS's /tmp -> /private/tmp) is itself a symlink.
395
+ def canonical(path)
396
+ existing = Pathname.new(path.to_s)
397
+ suffix = []
398
+ until existing.exist? || existing.root?
399
+ suffix.unshift(existing.basename.to_s)
400
+ existing = existing.dirname
401
+ end
402
+ base = existing.exist? ? existing.realpath.to_s : existing.to_s
403
+ suffix.empty? ? base : File.join(base, *suffix)
404
+ end
405
+
406
+ # The single write chokepoint: every AtomicFile.write in this class goes
407
+ # through here so a future regression upstream (a bad dir_for fallback,
408
+ # a mapper bug, a crafted rel path) fails closed with a typed error
409
+ # instead of writing outside the vault.
410
+ def safe_write(path, content)
411
+ raise PathTraversalError, "refusing to write outside the vault: #{path}" unless safe_inside_vault?(path)
412
+
413
+ AtomicFile.write(path, content)
414
+ end
415
+
349
416
  # ── Ownership ────────────────────────────────────────────────────
350
417
 
351
418
  def vault_owned_at_start?
@@ -363,12 +430,12 @@ module Woods
363
430
  # ── Helpers ──────────────────────────────────────────────────────
364
431
 
365
432
  def write_managed(written, rel, content)
366
- AtomicFile.write(@vault.join(rel), content)
433
+ safe_write(@vault.join(rel), content)
367
434
  written << rel
368
435
  end
369
436
 
370
437
  def write_json(written, rel, data)
371
- AtomicFile.write(@vault.join(rel), "#{JSON.pretty_generate(data)}\n")
438
+ safe_write(@vault.join(rel), "#{JSON.pretty_generate(data)}\n")
372
439
  written << rel
373
440
  end
374
441
 
@@ -19,6 +19,8 @@ module Woods
19
19
  # end
20
20
  #
21
21
  class PipelineGuard
22
+ SUPPORTED_OPERATIONS = %w[extraction embedding].freeze
23
+
22
24
  # @param state_dir [String] Directory for persisting state
23
25
  # @param cooldown [Integer] Minimum seconds between runs
24
26
  def initialize(state_dir:, cooldown: 300)
@@ -29,13 +31,44 @@ module Woods
29
31
 
30
32
  # Check if an operation is allowed (cooldown elapsed).
31
33
  #
34
+ # Fails closed on state this process cannot trust: :missing is the
35
+ # legitimate "never run" case and allows, but :corrupt and
36
+ # :permission_denied used to be indistinguishable from :missing —
37
+ # both parsed to `{}` (or crashed uncaught) and let the operation
38
+ # through, silently defeating the cooldown on exactly the state that
39
+ # means it can no longer be verified.
40
+ #
32
41
  # @param operation [Symbol, String] Operation name
33
42
  # @return [Boolean]
34
43
  def allow?(operation)
35
- last = last_run(operation)
36
- return true if last.nil?
44
+ case state_status
45
+ when :corrupt, :permission_denied
46
+ false
47
+ else
48
+ last = last_run(operation)
49
+ return true if last.nil?
37
50
 
38
- (Time.now - last) >= @cooldown
51
+ (Time.now - last) >= @cooldown
52
+ end
53
+ end
54
+
55
+ # Diagnose the on-disk cooldown state without side effects, distinguishing
56
+ # the three states {#allow?} used to collapse into one silent outcome —
57
+ # public callers (the MCP pipeline tools) need to tell a fresh install
58
+ # apart from a state file that needs operator attention.
59
+ #
60
+ # @return [Symbol] `:ok`, `:missing`, `:corrupt`, or `:permission_denied`
61
+ def state_status
62
+ return :missing unless File.exist?(@state_path)
63
+
64
+ parsed = JSON.parse(File.read(@state_path))
65
+ parsed.is_a?(Hash) ? :ok : :corrupt
66
+ rescue Errno::EACCES
67
+ :permission_denied
68
+ rescue JSON::ParserError
69
+ :corrupt
70
+ rescue Errno::ENOENT
71
+ :missing
39
72
  end
40
73
 
41
74
  # Record that an operation has just run.
@@ -43,23 +76,31 @@ module Woods
43
76
  # @param operation [Symbol, String] Operation name
44
77
  # @return [void]
45
78
  def record!(operation)
46
- FileUtils.mkdir_p(@state_dir)
47
- File.open(@state_path, File::RDWR | File::CREAT) do |f|
48
- f.flock(File::LOCK_EX)
49
- content = f.read
50
- state = if content.empty?
51
- {}
52
- else
53
- begin
54
- JSON.parse(content)
55
- rescue StandardError
56
- {}
57
- end
58
- end
79
+ with_locked_state do |state|
59
80
  state[operation.to_s] = Time.now.iso8601
60
- f.rewind
61
- f.write(JSON.generate(state))
62
- f.truncate(f.pos)
81
+ true
82
+ end
83
+ nil
84
+ end
85
+
86
+ # Reset one supported operation or all supported operations atomically.
87
+ #
88
+ # @param operation [Symbol, String] `:extraction`, `:embedding`, or `:all`
89
+ # @return [Boolean] true when at least one cooldown was removed
90
+ # @raise [ArgumentError] when the operation is unsupported
91
+ def reset!(operation)
92
+ operations = reset_operations(operation)
93
+ return false unless requested_state_present?(operations)
94
+
95
+ with_existing_locked_state do |state|
96
+ changed = false
97
+ operations.each do |key|
98
+ next unless state.key?(key)
99
+
100
+ state.delete(key)
101
+ changed = true
102
+ end
103
+ changed
63
104
  end
64
105
  end
65
106
 
@@ -79,12 +120,68 @@ module Woods
79
120
 
80
121
  private
81
122
 
123
+ def with_locked_state
124
+ FileUtils.mkdir_p(@state_dir)
125
+ File.open(@state_path, File::RDWR | File::CREAT) do |file|
126
+ file.flock(File::LOCK_EX)
127
+ state = parse_state(file.read)
128
+ changed = yield(state)
129
+ if changed
130
+ file.rewind
131
+ file.write(JSON.generate(state))
132
+ file.truncate(file.pos)
133
+ end
134
+ changed
135
+ end
136
+ end
137
+
138
+ def parse_state(content)
139
+ state = content.empty? ? {} : JSON.parse(content)
140
+ state.is_a?(Hash) ? state : {}
141
+ rescue JSON::ParserError
142
+ {}
143
+ end
144
+
145
+ def requested_state_present?(operations)
146
+ File.open(@state_path, File::RDONLY) do |file|
147
+ file.flock(File::LOCK_SH)
148
+ state = parse_state(file.read)
149
+ operations.any? { |key| state.key?(key) }
150
+ end
151
+ rescue Errno::ENOENT
152
+ false
153
+ end
154
+
155
+ def with_existing_locked_state
156
+ File.open(@state_path, File::RDWR) do |file|
157
+ file.flock(File::LOCK_EX)
158
+ state = parse_state(file.read)
159
+ changed = yield(state)
160
+ if changed
161
+ file.rewind
162
+ file.write(JSON.generate(state))
163
+ file.truncate(file.pos)
164
+ end
165
+ changed
166
+ end
167
+ rescue Errno::ENOENT
168
+ false
169
+ end
170
+
171
+ def reset_operations(operation)
172
+ key = operation.to_s
173
+ return SUPPORTED_OPERATIONS if key == 'all'
174
+ return [key] if SUPPORTED_OPERATIONS.include?(key)
175
+
176
+ raise ArgumentError, "Unsupported pipeline operation: #{operation}"
177
+ end
178
+
82
179
  # @return [Hash]
83
180
  def read_state
84
181
  return {} unless File.exist?(@state_path)
85
182
 
86
- JSON.parse(File.read(@state_path))
87
- rescue JSON::ParserError
183
+ parse_state(File.read(@state_path))
184
+ rescue Errno::ENOENT, Errno::EACCES
88
185
  {}
89
186
  end
90
187
  end
@@ -3,6 +3,9 @@
3
3
  require 'json'
4
4
  require 'time'
5
5
 
6
+ require_relative '../atomic_file'
7
+ require_relative '../generation'
8
+
6
9
  module Woods
7
10
  module Operator
8
11
  # Reports pipeline status by reading extraction output metadata.
@@ -43,13 +46,27 @@ module Woods
43
46
 
44
47
  private
45
48
 
49
+ # Read manifest.json via AtomicFile.read (a bare File.read tags the bytes
50
+ # with the process's default external encoding — US-ASCII under LANG=C —
51
+ # so a non-ASCII branch name raised EncodingError out of JSON.parse). An
52
+ # unparseable or unreadable manifest degrades to nil, the same as a
53
+ # missing one: the report becomes :not_extracted rather than an error.
54
+ #
55
+ # Resolved through {Woods::Generation#payload_dir}: a payload-born
56
+ # index keeps +manifest.json+ only under the directory the published
57
+ # generation names, never at +@output_dir+ directly, so reading the
58
+ # root path unconditionally reported :not_extracted against a fresh
59
+ # extraction. Resolves to +@output_dir+ itself for a flat (pre-payload)
60
+ # index, so this is a no-op for every index that predates payloads.
61
+ #
46
62
  # @return [Hash, nil]
47
63
  def read_manifest
48
- path = File.join(@output_dir, 'manifest.json')
64
+ generation = Woods::Generation.new(output_dir: @output_dir)
65
+ path = generation.payload_dir(generation.current).join('manifest.json')
49
66
  return nil unless File.exist?(path)
50
67
 
51
- JSON.parse(File.read(path))
52
- rescue JSON::ParserError
68
+ JSON.parse(AtomicFile.read(path))
69
+ rescue JSON::ParserError, EncodingError, SystemCallError
53
70
  nil
54
71
  end
55
72