woods 1.6.1 → 2.0.0.beta2

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  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 +655 -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 +455 -0
  19. data/docs/INTERNALS.md +418 -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 +213 -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 +80 -11
  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 +171 -2
  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/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -1,15 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
3
4
  require 'json'
4
5
  require 'logger'
5
6
  require 'mcp'
6
7
  require 'open3'
7
8
  require 'time'
8
9
  require 'set'
10
+ require 'uri'
11
+ require_relative '../atomic_file'
12
+ require_relative '../generation'
9
13
  require_relative '../tasks'
14
+ require_relative '../watch/status'
10
15
  require_relative '../filename_utils'
11
16
  require_relative '../update_check'
17
+ require_relative 'bootstrap_state'
18
+ require_relative 'errors'
12
19
  require_relative 'index_reader'
20
+ require_relative 'index_reader_pinning'
21
+ require_relative 'protocol_policy'
22
+ require_relative 'tasks/extension'
23
+ require_relative 'tasks/request_capture'
24
+ require_relative 'tasks/store'
25
+ require_relative 'tool_contract'
13
26
  require_relative 'tool_response_renderer'
14
27
  require_relative 'version_aware_tool_dispatch'
15
28
 
@@ -39,6 +52,25 @@ module Woods
39
52
  @pipeline_mutex = Mutex.new
40
53
  @pipeline_in_flight = {}
41
54
 
55
+ # Seconds `pipeline_extract` will wait for the on-disk extraction lock
56
+ # before reporting contention (#170). Deliberately short: the tool
57
+ # answers a live agent, and "another writer is mid-run, retry" is a
58
+ # better answer than a multi-minute stall (the rake writers wait
59
+ # `LOCK_STALE_TIMEOUT` because a human started them and asked them to
60
+ # finish). Module-level rather than inside `class << self`, so specs can
61
+ # stub it and lexical lookup from the tool definitions still finds it.
62
+ PIPELINE_LOCK_WAIT = 2.0
63
+
64
+ # Human-readable pipeline names for the `already_running` message.
65
+ PIPELINE_LABELS = { extraction: 'Extraction', embedding: 'Embedding' }.freeze
66
+
67
+ # How many traversal nodes `dependents` and `dependencies` return when
68
+ # the caller names no limit. A hub at the default depth 2 used to return
69
+ # about 178 KB with nothing saying it had been anything but complete. The
70
+ # controls that actually shrink the answer are `depth`, `types` and
71
+ # `via`; `limit` and `offset` only page what those leave (B-183).
72
+ DEFAULT_TRAVERSAL_LIMIT = 50
73
+
42
74
  class << self
43
75
  # Build a configured MCP::Server with all tools and resources.
44
76
  #
@@ -70,10 +102,11 @@ module Woods
70
102
  respond_err = method(:error_response)
71
103
  op_missing = lambda do |tool|
72
104
  error_response(
73
- 'Pipeline operator is not configured. Pass `operator:` to Woods::MCP::Server.build ' \
74
- 'or use Woods::MCP::Bootstrapper to wire StatusReporter, ErrorEscalator, and PipelineGuard.',
105
+ 'Pipeline operator is not configured. Pass `operator:` (a StatusReporter, ' \
106
+ 'ErrorEscalator, and PipelineGuard) to Woods::MCP::Server.build when embedding ' \
107
+ 'the server — neither packaged executable wires one today.',
75
108
  code: :not_configured, config_key: 'operator',
76
- doc_link: 'docs/OPERATOR_GUIDE.md', tool: tool
109
+ doc_link: 'docs/MCP_TOOL_COOKBOOK.md#conditional-tools--wiring', tool: tool
77
110
  )
78
111
  end
79
112
  fb_missing = lambda do |tool|
@@ -81,7 +114,7 @@ module Woods
81
114
  'Feedback store is not configured. Pass `feedback_store:` to Woods::MCP::Server.build ' \
82
115
  'to enable retrieval feedback capture.',
83
116
  code: :not_configured, config_key: 'feedback_store',
84
- doc_link: 'docs/FEEDBACK_STORE.md', tool: tool
117
+ doc_link: 'docs/MCP_TOOL_COOKBOOK.md#conditional-tools--wiring', tool: tool
85
118
  )
86
119
  end
87
120
  snap_missing = lambda do |tool|
@@ -89,7 +122,7 @@ module Woods
89
122
  'Snapshot store is not configured. Set `enable_snapshots: true` in Woods.configure ' \
90
123
  'and pass `snapshot_store:` to Woods::MCP::Server.build.',
91
124
  code: :not_configured, config_key: 'enable_snapshots',
92
- doc_link: 'docs/TEMPORAL_SNAPSHOTS.md', tool: tool
125
+ doc_link: 'docs/MCP_TOOL_COOKBOOK.md#conditional-tools--wiring', tool: tool
93
126
  )
94
127
  end
95
128
 
@@ -97,22 +130,41 @@ module Woods
97
130
  name: 'woods',
98
131
  version: Woods::VERSION,
99
132
  resources: resources,
100
- resource_templates: resource_templates
133
+ resource_templates: resource_templates,
134
+ configuration: ::MCP::Configuration.new.merge(::MCP.configuration),
135
+ **ProtocolPolicy.cache_hints
101
136
  )
102
137
  # Rewrite "Tool not found" into version-aware update guidance for agents
103
138
  # running against an older gem than the skill they're following assumes.
104
139
  server.singleton_class.prepend(VersionAwareToolDispatch)
140
+ # Make the per-request Tasks opt-in reachable from a tool handler,
141
+ # which otherwise only sees `arguments`.
142
+ server.singleton_class.prepend(Tasks::RequestCapture)
143
+
144
+ # The Tasks extension backs the two long-running tools. Registered
145
+ # unconditionally rather than only alongside `operator`, because
146
+ # `tasks/get` must keep answering for a handle minted by a *previous*
147
+ # process — the crash-resilience case is precisely the one where this
148
+ # server was restarted and may come up wired differently.
149
+ task_store = Tasks::Store.new(index_dir)
150
+ Tasks::Extension.install(server, store: task_store)
105
151
 
106
152
  define_lookup_tool(server, reader, respond, respond_err, renderer)
107
153
  define_search_tool(server, reader, respond, respond_err, renderer)
108
154
  define_traversal_tool(server, reader, respond, renderer,
109
155
  name: 'dependencies',
110
- description: 'Traverse forward dependencies of a unit (what it depends on). Returns a BFS tree with depth.',
156
+ description: 'Traverse forward dependencies of a unit (what it depends on). ' \
157
+ 'Narrow with depth, types and via first: they shrink the answer, ' \
158
+ 'while limit and offset only page it. Returns a BFS tree with ' \
159
+ "depth, bounded to #{DEFAULT_TRAVERSAL_LIMIT} nodes by default.",
111
160
  reader_method: :traverse_dependencies,
112
161
  render_key: :dependencies)
113
162
  define_traversal_tool(server, reader, respond, renderer,
114
163
  name: 'dependents',
115
- description: 'Traverse reverse dependencies of a unit (what depends on it). Returns a BFS tree with depth.',
164
+ description: 'Traverse reverse dependencies of a unit (what depends on it). ' \
165
+ 'Narrow with depth, types and via first: they shrink the answer, ' \
166
+ 'while limit and offset only page it. Returns a BFS tree with ' \
167
+ "depth, bounded to #{DEFAULT_TRAVERSAL_LIMIT} nodes by default.",
116
168
  reader_method: :traverse_dependents,
117
169
  render_key: :dependents)
118
170
  define_structure_tool(server, reader, respond, renderer)
@@ -121,9 +173,9 @@ module Woods
121
173
  define_pagerank_tool(server, reader, respond, renderer)
122
174
  define_framework_tool(server, reader, respond, renderer)
123
175
  define_recent_changes_tool(server, reader, respond, renderer)
124
- define_reload_tool(server, reader, respond, retriever_reloader)
125
- define_retrieve_tool(server, retriever, respond, respond_err)
126
- define_trace_flow_tool(server, reader, index_dir, respond, respond_err, renderer)
176
+ define_reload_tool(server, reader, respond, respond_err, retriever_reloader, bootstrap_state)
177
+ define_retrieve_tool(server, retriever, respond, respond_err, bootstrap_state)
178
+ define_trace_flow_tool(server, reader, respond, respond_err, renderer)
127
179
  # Conditionally register collaborator-dependent tools. Historically
128
180
  # all 15 stubs were registered unconditionally and returned
129
181
  # isError: true when the wiring was missing — that added token
@@ -131,14 +183,19 @@ module Woods
131
183
  # try tools guaranteed to fail. Only register when the collaborator
132
184
  # is wired, so tools/list reflects what the server can actually do.
133
185
  define_session_trace_tool(server, reader, respond, respond_err) if session_tracer_wired?
134
- define_operator_tools(server, operator, respond, respond_err, op_missing) if operator
186
+ define_operator_tools(server, operator, respond, respond_err, op_missing, task_store) if operator
135
187
  define_feedback_tools(server, feedback_store, respond, respond_err, fb_missing) if feedback_store
136
188
  define_snapshot_tools(server, snapshot_store, respond, respond_err, snap_missing) if snapshot_store
137
189
  define_notion_sync_tool(server, reader, index_dir, respond, respond_err) if notion_wired?
138
190
  define_woods_status_tool(server, reader, retriever, index_dir, bootstrap_state, respond)
139
191
  register_resource_handler(server, reader)
192
+ ToolContract.apply!(server)
193
+ IndexReaderPinning.install(server, reader: reader)
140
194
 
141
- server
195
+ # Last, after every conditional registration above — the whole point is
196
+ # that a host with Notion wired advertises the same tool order as one
197
+ # without it.
198
+ ProtocolPolicy.sort_tools!(server)
142
199
  end
143
200
 
144
201
  private
@@ -168,7 +225,7 @@ module Woods
168
225
 
169
226
  # Notion export needs both an API token and at least one database ID.
170
227
  # A non-blank NOTION_API_TOKEN env var overrides the config token (see
171
- # docs/NOTION_EXPORT.md). Resolution goes through
228
+ # docs/NOTION_INTEGRATION.md). Resolution goes through
172
229
  # Woods.resolve_notion_token so a blank env var is treated as absent
173
230
  # (rather than masking a valid configured token) — matching the
174
231
  # exporter and the notion_sync handler.
@@ -182,7 +239,17 @@ module Woods
182
239
  end
183
240
 
184
241
  def text_response(text)
185
- ::MCP::Tool::Response.new([{ type: 'text', text: text }])
242
+ structured = { text: text }
243
+ structured[:data] = JSON.parse(text)
244
+ ::MCP::Tool::Response.new(
245
+ [{ type: 'text', text: text }],
246
+ structured_content: structured
247
+ )
248
+ rescue JSON::ParserError
249
+ ::MCP::Tool::Response.new(
250
+ [{ type: 'text', text: text }],
251
+ structured_content: structured
252
+ )
186
253
  end
187
254
 
188
255
  # Build a structured error response that carries machine-readable
@@ -203,6 +270,7 @@ module Woods
203
270
  ::MCP::Tool::Response.new(
204
271
  [{ type: 'text', text: message }],
205
272
  error: true,
273
+ structured_content: { text: message },
206
274
  meta: meta
207
275
  )
208
276
  end
@@ -261,7 +329,7 @@ module Woods
261
329
  # Load a precomputed flow document written by FlowPrecomputer, when
262
330
  # `config.precompute_flows` was enabled during extraction. Returns nil
263
331
  # when the entry point is missing a method suffix, the JSON file isn't
264
- # on disk, or the file can't be parsed — callers fall back to
332
+ # on disk, or the file can't be read or parsed — callers fall back to
265
333
  # FlowAssembler.
266
334
  #
267
335
  # @param index_dir [String]
@@ -278,11 +346,27 @@ module Woods
278
346
  # flows/) using the SAME transform FlowPrecomputer writes with, so a
279
347
  # legitimately precomputed flow always resolves to the file on disk.
280
348
  filename = Woods::FilenameUtils.flow_filename(controller, action)
349
+ # The path is derived from the entry point and joined against THIS
350
+ # process's index_dir — the path *values* in flow_index.json /
351
+ # metadata[:flow_paths] are never consulted. That is what keeps
352
+ # both formats working unchanged: post-#190 indexes persist
353
+ # output_dir-relative values ("flows/X_y.json"), while pre-#190
354
+ # indexes persisted the extraction machine's absolute paths (e.g.
355
+ # container-side "/app/tmp/woods/flows/X_y.json"), which need not
356
+ # resolve on the reading host at all.
281
357
  path = File.join(index_dir, 'flows', filename)
282
358
  return nil unless File.exist?(path)
283
359
 
284
- Woods::FlowDocument.from_h(JSON.parse(File.read(path)))
285
- rescue JSON::ParserError, Errno::ENOENT
360
+ # AtomicFile.read, not File.read: flow documents carry free-text
361
+ # (args_hint / condition strings) that can be non-ASCII, and a bare
362
+ # read under LANG=C tags the result US-ASCII so the first
363
+ # JSON.parse raises Encoding::InvalidByteSequenceError.
364
+ Woods::FlowDocument.from_h(JSON.parse(Woods::AtomicFile.read(path)))
365
+ rescue JSON::ParserError, Errno::ENOENT, EncodingError
366
+ # EncodingError included so ANY unreadable precomputed flow (torn,
367
+ # corrupt, mis-encoded bytes) degrades to query-time reassembly as
368
+ # documented, instead of escaping to trace_flow's generic handler
369
+ # as an internal_error.
286
370
  nil
287
371
  end
288
372
 
@@ -307,6 +391,37 @@ module Woods
307
391
  container["#{key}_offset"] = offset if offset.positive?
308
392
  end
309
393
 
394
+ # Page a traversal result's `nodes` hash in place, in BFS order.
395
+ #
396
+ # Mirrors {#paginate_section}'s metadata keys (`nodes_total`,
397
+ # `nodes_truncated`, `nodes_offset`) so both renderers print the one
398
+ # truncation line they already had for `graph_analysis`. A page that
399
+ # holds every node adds no keys at all, so a small result renders
400
+ # exactly as it did before the bound existed (B-183).
401
+ #
402
+ # `nodes_total` marks *any* partial answer, not only one with more
403
+ # behind it. Keying it on `total > offset + limit` left the last page
404
+ # of a walk indistinguishable from a complete one: 21 nodes of 121,
405
+ # with nothing saying 100 were skipped. `nodes_truncated` still means
406
+ # what it always did, "there is more after this page".
407
+ #
408
+ # @param result [Hash] traversal result, mutated
409
+ # @param limit [Integer] maximum nodes to retain
410
+ # @param offset [Integer] nodes to skip from the front
411
+ # @return [void]
412
+ def paginate_traversal_nodes(result, limit, offset)
413
+ nodes = result[:nodes]
414
+ return unless nodes.is_a?(Hash)
415
+
416
+ total = nodes.size
417
+ return if offset.zero? && total <= limit
418
+
419
+ result[:nodes] = nodes.to_a.drop(offset).take(limit).to_h
420
+ result[:nodes_total] = total if offset.positive? || total > limit
421
+ result[:nodes_truncated] = true if total > offset + limit
422
+ result[:nodes_offset] = offset if offset.positive?
423
+ end
424
+
310
425
  def define_lookup_tool(server, reader, respond, respond_err, renderer)
311
426
  coerce = method(:coerce_array)
312
427
  server.define_tool(
@@ -386,7 +501,7 @@ module Woods
386
501
  description: 'Restrict scan to these unit types: model, controller, service, job, mailer, etc.'
387
502
  },
388
503
  fields: {
389
- type: 'array', items: { type: 'string' },
504
+ type: 'array', items: { type: 'string', enum: %w[identifier metadata source_code] },
390
505
  description: 'Fields to search: identifier (default), source_code, metadata'
391
506
  },
392
507
  limit: { type: 'integer', description: 'Maximum results (default: 20)' },
@@ -440,6 +555,7 @@ module Woods
440
555
  def define_traversal_tool(server, reader, respond, renderer, name:, description:, reader_method:, render_key:)
441
556
  coerce = method(:coerce_array)
442
557
  coerce_int = method(:coerce_integer)
558
+ paginate_nodes = method(:paginate_traversal_nodes)
443
559
  server.define_tool(
444
560
  name: name,
445
561
  description: description,
@@ -452,25 +568,34 @@ module Woods
452
568
  description: 'Filter to these types'
453
569
  },
454
570
  via: {
455
- type: 'array', items: { type: 'string' },
571
+ anyOf: [
572
+ { type: 'string' },
573
+ { type: 'array', items: { type: 'string' } }
574
+ ],
456
575
  description: 'Filter by relationship type. Accepts either a single string ' \
457
576
  "(e.g. 'code_reference') or an array " \
458
577
  "(e.g. ['code_reference','render']); both forms are coerced to an array internally. " \
459
578
  'Known values: link_to, redirect_to, form_action, render, code_reference, ' \
460
- 'belongs_to, has_many, has_one, has_and_belongs_to_many.'
461
- }
579
+ 'belongs_to, has_many, has_one, has_and_belongs_to_many, polymorphic_interface.'
580
+ },
581
+ limit: { type: 'integer',
582
+ description: "Maximum nodes to return (default: #{DEFAULT_TRAVERSAL_LIMIT})" },
583
+ offset: { type: 'integer', description: 'Skip this many nodes (default: 0)' }
462
584
  },
463
585
  required: ['identifier']
464
586
  }
465
- ) do |identifier:, server_context:, depth: nil, types: nil, via: nil|
587
+ ) do |identifier:, server_context:, depth: nil, types: nil, via: nil, limit: nil, offset: nil|
466
588
  types = coerce.call(types)
467
589
  via = coerce.call(via)
468
590
  depth = coerce_int.call(depth)
591
+ limit = coerce_int.call(limit)
592
+ offset = coerce_int.call(offset)
469
593
  result = reader.send(reader_method, identifier, depth: depth || 2, types: types, via: via)
470
594
  if result[:found] == false
471
595
  result[:message] =
472
596
  "Identifier '#{identifier}' not found in the index. Use 'search' to find valid identifiers."
473
597
  end
598
+ paginate_nodes.call(result, limit || DEFAULT_TRAVERSAL_LIMIT, offset || 0)
474
599
  respond.call(renderer.render(render_key, result))
475
600
  end
476
601
  end
@@ -499,12 +624,16 @@ module Woods
499
624
  coerce_int = method(:coerce_integer)
500
625
  server.define_tool(
501
626
  name: 'graph_analysis',
502
- description: 'Get structural analysis of the dependency graph: orphans, dead ends, hubs, cycles, and bridges.',
627
+ description: 'Get structural analysis of the dependency graph: orphans, dead ends, hubs, cycles, bridges, ' \
628
+ 'cross_database_edges (associations and foreign keys across databases), ' \
629
+ 'volatile_dependencies (edges into units that change far more often than the dependent), ' \
630
+ 'and undeclared_package_edges (edges that cross a package boundary the source package ' \
631
+ 'never declared).',
503
632
  input_schema: {
504
633
  properties: {
505
634
  analysis: {
506
635
  type: 'string',
507
- enum: %w[orphans dead_ends hubs cycles bridges all],
636
+ enum: ToolResponseRenderer::GRAPH_ANALYSIS_SECTIONS + %w[all],
508
637
  description: 'Which analysis to return. Default: all'
509
638
  },
510
639
  limit: { type: 'integer', description: 'Limit results per section (default: 20)' },
@@ -521,7 +650,7 @@ module Woods
521
650
  result = if section == 'all'
522
651
  if limit || effective_offset.positive?
523
652
  truncated = data.dup
524
- %w[orphans dead_ends hubs cycles bridges].each do |key|
653
+ ToolResponseRenderer::GRAPH_ANALYSIS_SECTIONS.each do |key|
525
654
  paginate.call(truncated, key, limit, effective_offset)
526
655
  end
527
656
  truncated
@@ -529,7 +658,7 @@ module Woods
529
658
  data
530
659
  end
531
660
  else
532
- single = { section => data[section], 'stats' => data['stats'] }
661
+ single = { section => data[section] || [], 'stats' => data['stats'] }
533
662
  paginate.call(single, section, limit, effective_offset) if limit || effective_offset.positive?
534
663
  single
535
664
  end
@@ -665,38 +794,86 @@ module Woods
665
794
  end
666
795
  end
667
796
 
668
- def define_reload_tool(server, reader, respond, retriever_reloader)
797
+ def define_reload_tool(server, reader, respond, respond_err, retriever_reloader, bootstrap_state)
669
798
  server.define_tool(
670
799
  name: 'reload',
671
800
  description: 'Reload extraction data from disk. Use after re-running extraction or woods:embed to pick ' \
672
801
  'up changes without restarting the server. Refreshes the JSON index (manifest, dependency ' \
673
802
  'graph, unit cache) AND re-hydrates the retriever\'s in-memory vector/metadata/graph ' \
674
- 'stores from the latest dumps. Durable backends (pgvector, Qdrant) are auto-refreshed ' \
675
- 'externally — their counts in the response reflect the read-through state.',
803
+ 'stores from the latest dumps. The refresh is transactional (build-then-swap): candidate ' \
804
+ 'stores are built off-side, and on any failure nothing is swapped — the previous ' \
805
+ 'generation keeps being served and a degraded_index error names it. Durable backends ' \
806
+ '(pgvector, Qdrant) are auto-refreshed externally — their counts in the response reflect ' \
807
+ 'the read-through state.',
676
808
  input_schema: { type: 'object', properties: {} }
677
809
  ) do |server_context:|
678
- reader.reload!
679
- manifest = reader.manifest
680
- payload = {
681
- reloaded: true,
682
- extracted_at: manifest['extracted_at'],
683
- total_units: manifest['total_units'],
684
- counts: manifest['counts']
685
- }
686
810
  if retriever_reloader
687
- begin
688
- payload[:retriever] = retriever_reloader.call
811
+ counts = begin
812
+ retriever_reloader.call(reader)
813
+ rescue Woods::MCP::ReloadDegraded => e
814
+ reason = "#{e.class}: #{e.message}"
815
+ bootstrap_state&.record_reload_failure(generation: e.generation, stores: e.stores, reason: reason)
816
+ next respond_err.call(
817
+ "Reload failed; nothing was swapped. Generation #{e.generation} is still being served: " \
818
+ "#{reason} Fix the underlying store or index issue, then invoke reload again.",
819
+ code: :degraded_index,
820
+ tool: 'reload',
821
+ degraded: true,
822
+ phase: 'reload',
823
+ generation: e.generation,
824
+ stores: e.stores,
825
+ reason: reason
826
+ )
689
827
  rescue StandardError => e
690
- payload[:retriever] = { error: "#{e.class}: #{e.message}" }
828
+ # A foreign error from a custom reloader leaves the reader and
829
+ # stores untouched too (the transaction is all-or-nothing), so
830
+ # it maps to the same reload-phase degraded shape with best-
831
+ # effort fields.
832
+ generation = reader.loaded_generation || 0
833
+ reason = "#{e.class}: #{e.message}"
834
+ bootstrap_state&.record_reload_failure(generation: generation,
835
+ stores: %w[vector metadata graph], reason: reason)
836
+ next respond_err.call(
837
+ "Reload failed; nothing was swapped. Generation #{generation} is still being served: " \
838
+ "#{reason} Fix the underlying store or index issue, then invoke reload again.",
839
+ code: :degraded_index,
840
+ tool: 'reload',
841
+ degraded: true,
842
+ phase: 'reload',
843
+ generation: generation,
844
+ stores: %w[vector metadata graph],
845
+ reason: reason
846
+ )
847
+ end
848
+
849
+ manifest = reader.manifest
850
+ payload = {
851
+ reloaded: true,
852
+ extracted_at: manifest['extracted_at'],
853
+ total_units: manifest['total_units'],
854
+ counts: manifest['counts'],
855
+ retriever: counts
856
+ }
857
+ respond.call(JSON.pretty_generate(payload))
858
+ else
859
+ reader.with_exclusive_reload do |manifest|
860
+ payload = {
861
+ reloaded: true,
862
+ extracted_at: manifest['extracted_at'],
863
+ total_units: manifest['total_units'],
864
+ counts: manifest['counts']
865
+ }
866
+ respond.call(JSON.pretty_generate(payload))
691
867
  end
692
868
  end
693
- respond.call(JSON.pretty_generate(payload))
694
869
  end
695
870
  end
696
871
 
697
- def define_retrieve_tool(server, retriever, respond, respond_err)
872
+ def define_retrieve_tool(server, retriever, respond, respond_err, bootstrap_state = nil)
698
873
  coerce_int = method(:coerce_integer)
699
874
  coerce = method(:coerce_array)
875
+ stale_check = method(:stale_index_result?)
876
+ degraded_response = method(:degraded_retrieval_response)
700
877
  server.define_tool(
701
878
  name: 'codebase_retrieve',
702
879
  description: 'Semantic search: retrieve relevant code units for a natural-language question. ' \
@@ -757,13 +934,47 @@ module Woods
757
934
  budget = coerce_int.call(budget)
758
935
  types = coerce.call(types)
759
936
  exclude_types = coerce.call(exclude_types)
760
- if retriever
761
- result = retriever.retrieve(
762
- query,
763
- budget: budget || 8000,
764
- types: types,
765
- exclude_types: exclude_types
937
+ # M6: a hydration failure at boot left the in-memory stores
938
+ # empty. Every query would come back as a clean empty result —
939
+ # indistinguishable from "no matches" — so surface the degraded
940
+ # state as typed metadata instead of answering with nothing.
941
+ if bootstrap_state&.hydration_failed?
942
+ failures = bootstrap_state.hydration_failures
943
+ next degraded_response.call(
944
+ respond_err,
945
+ reason: failures.values.map { |e| "#{e.class}: #{e.message}" }.join('; '),
946
+ stores: failures.keys.map(&:to_s),
947
+ phase: 'boot'
766
948
  )
949
+ end
950
+ if retriever
951
+ begin
952
+ result = retriever.retrieve(
953
+ query,
954
+ budget: budget || 8000,
955
+ types: types,
956
+ exclude_types: exclude_types
957
+ )
958
+ rescue Woods::Retriever::StoreError => e
959
+ # M8: a metadata-store failure mid-query must not surface as
960
+ # a raw raise through the tool boundary (or as the misleading
961
+ # :absent / empty answers the retriever used to produce).
962
+ next degraded_response.call(
963
+ respond_err,
964
+ reason: e.message,
965
+ stores: [e.store],
966
+ phase: 'query'
967
+ )
968
+ end
969
+ if stale_check.call(result)
970
+ next respond_err.call(
971
+ 'The vector index appears stale: matches were found but their source data is ' \
972
+ 'missing (likely a deleted or renamed unit). Re-run `woods:embed` (or ' \
973
+ '`woods:embed_incremental`) to refresh the index, then retry.',
974
+ code: :stale_index,
975
+ tool: 'codebase_retrieve'
976
+ )
977
+ end
767
978
  respond.call(result.context)
768
979
  else
769
980
  respond_err.call(
@@ -780,7 +991,51 @@ module Woods
780
991
  end
781
992
  end
782
993
 
783
- def define_trace_flow_tool(server, reader, index_dir, respond, respond_err, renderer)
994
+ # Detect a stale vector index: candidates matched the query but every
995
+ # one of them pointed at a unit the metadata store no longer has
996
+ # (deleted/renamed since the last embed). Distinguishes that case
997
+ # from a genuine "no matches" so the tool can say what happened
998
+ # instead of returning near-empty context as clean success.
999
+ #
1000
+ # @param result [Woods::Retriever::RetrievalResult] (or a test double
1001
+ # with the same shape — +trace+ may be absent/nil on older doubles)
1002
+ # @return [Boolean]
1003
+ def stale_index_result?(result)
1004
+ trace = result.respond_to?(:trace) ? result.trace : nil
1005
+ return false unless trace
1006
+
1007
+ trace.ranked_count.to_i.positive? &&
1008
+ trace.skipped_missing_metadata.to_i.positive? &&
1009
+ Array(result.sources).empty?
1010
+ end
1011
+
1012
+ # Typed degraded-metadata response for codebase_retrieve (M6/M8). A
1013
+ # degraded retriever must never answer with a clean empty result: the
1014
+ # response is a tool error carrying the machine-readable degraded
1015
+ # marker, which stores are affected, and the underlying reason.
1016
+ #
1017
+ # @param respond_err [Method] the tool error-response builder
1018
+ # @param reason [String] human-readable failure summary
1019
+ # @param stores [Array<String>] affected store component names
1020
+ # @param phase [String] 'boot' (hydration failure) or 'query'
1021
+ # (store failure at query time)
1022
+ # @return [MCP::Tool::Response]
1023
+ def degraded_retrieval_response(respond_err, reason:, stores:, phase:)
1024
+ respond_err.call(
1025
+ "Semantic search is degraded: #{reason}. The affected store(s) return no data, so " \
1026
+ 'queries would come back empty — this is NOT "no results". ' \
1027
+ 'Run `woods_status` for the bootstrap report, re-run `woods:embed` if the index is stale, ' \
1028
+ 'and restart the server once the store is loadable.',
1029
+ code: :degraded_index,
1030
+ tool: 'codebase_retrieve',
1031
+ degraded: true,
1032
+ phase: phase,
1033
+ stores: stores,
1034
+ reason: reason
1035
+ )
1036
+ end
1037
+
1038
+ def define_trace_flow_tool(server, reader, respond, respond_err, renderer)
784
1039
  require_relative '../flow_assembler'
785
1040
  require_relative '../flow_document'
786
1041
  require_relative '../dependency_graph'
@@ -810,10 +1065,10 @@ module Woods
810
1065
  # extraction (gated on `config.precompute_flows`) — it avoids
811
1066
  # re-parsing source on every request. Fall back to query-time
812
1067
  # reassembly when no precomputed document exists.
813
- flow_doc = load_precomputed.call(index_dir, entry_point)
1068
+ flow_doc = load_precomputed.call(reader.payload_dir, entry_point)
814
1069
  flow_doc ||= begin
815
1070
  graph = reader.dependency_graph
816
- assembler = Woods::FlowAssembler.new(graph: graph, extracted_dir: index_dir)
1071
+ assembler = Woods::FlowAssembler.new(graph: graph, extracted_dir: reader.payload_dir.to_s)
817
1072
  assembler.assemble(entry_point, max_depth: max_depth)
818
1073
  end
819
1074
 
@@ -823,6 +1078,14 @@ module Woods
823
1078
  # surface it, rather than wrapping the error payload in a
824
1079
  # successful response — consistent with session_trace and
825
1080
  # codebase_retrieve.
1081
+ if ToolContract.artifact_error?(e)
1082
+ next respond_err.call(
1083
+ 'trace_flow could not read a required Index artifact.',
1084
+ code: :corrupt_artifact,
1085
+ tool: 'trace_flow'
1086
+ )
1087
+ end
1088
+
826
1089
  respond_err.call(
827
1090
  "trace_flow failed: #{e.message}",
828
1091
  code: :internal_error,
@@ -854,7 +1117,7 @@ module Woods
854
1117
  'and set `session_tracer_enabled = true` in Woods.configure.',
855
1118
  code: :not_configured,
856
1119
  config_key: 'session_store',
857
- doc_link: 'docs/SESSION_TRACER.md',
1120
+ doc_link: 'docs/MCP_TOOL_COOKBOOK.md#conditional-tools--wiring',
858
1121
  tool: 'session_trace'
859
1122
  )
860
1123
  end
@@ -876,9 +1139,9 @@ module Woods
876
1139
  end
877
1140
  end
878
1141
 
879
- def define_operator_tools(server, operator, respond, respond_err, op_missing)
880
- define_pipeline_extract_tool(server, operator, respond, respond_err, op_missing)
881
- define_pipeline_embed_tool(server, operator, respond, respond_err, op_missing)
1142
+ def define_operator_tools(server, operator, respond, respond_err, op_missing, task_store)
1143
+ define_pipeline_extract_tool(server, operator, respond, respond_err, op_missing, task_store)
1144
+ define_pipeline_embed_tool(server, operator, respond, respond_err, op_missing, task_store)
882
1145
  define_pipeline_status_tool(server, operator, respond, respond_err, op_missing)
883
1146
  define_pipeline_diagnose_tool(server, operator, respond, respond_err, op_missing)
884
1147
  define_pipeline_repair_tool(server, operator, respond, respond_err, op_missing)
@@ -891,7 +1154,8 @@ module Woods
891
1154
  define_retrieval_suggest_tool(server, feedback_store, respond, fb_missing)
892
1155
  end
893
1156
 
894
- def define_pipeline_extract_tool(server, operator, respond, respond_err, op_missing)
1157
+ def define_pipeline_extract_tool(server, operator, respond, respond_err, op_missing, task_store)
1158
+ cooldown = method(:cooldown_error)
895
1159
  server.define_tool(
896
1160
  name: 'pipeline_extract',
897
1161
  description: 'Trigger a codebase extraction pipeline run. Checks rate limits before proceeding.',
@@ -926,50 +1190,114 @@ module Woods
926
1190
  end
927
1191
 
928
1192
  guard = operator[:pipeline_guard]
929
- if guard && !guard.allow?(:extraction)
930
- next respond_err.call(
931
- 'Extraction is rate-limited. Try again later.',
932
- code: :rate_limited,
933
- tool: 'pipeline_extract',
934
- retry_after_seconds: 300
935
- )
1193
+ if (blocked = cooldown.call(guard, :extraction, 'pipeline_extract'))
1194
+ next blocked
936
1195
  end
937
1196
 
938
- # Acquire the in-process lock BEFORE recording to the guard.
939
- # Otherwise a refused "already running" request still resets
940
- # the cooldown clock and blocks the next legitimate attempt
941
- # for the full 5-minute window once the current run finishes.
942
- unless Woods::MCP::Server.send(:pipeline_start, :extraction)
943
- next respond_err.call(
944
- 'Extraction pipeline is already running. Wait for it to complete.',
945
- code: :already_running,
946
- tool: 'pipeline_extract'
947
- )
1197
+ build_extraction_runner = lambda do |output_dir|
1198
+ lambda do
1199
+ # exe/woods-mcp deliberately loads no extraction machinery, so
1200
+ # Woods::Extractor is not defined in a standalone index-server
1201
+ # process. Resolve it here, the same lazy require Woods.extract!
1202
+ # uses — otherwise every pipeline_extract run dies in the
1203
+ # background with NameError.
1204
+ require_relative '../extractor' unless defined?(Woods::Extractor)
1205
+ extractor = Woods::Extractor.new(output_dir: output_dir)
1206
+ incremental ? extractor.extract_changed(files) : extractor.extract_all
1207
+ end
948
1208
  end
949
1209
 
950
- # Lock acquired — now it's safe to record the run.
951
- guard&.record!(:extraction)
1210
+ next Woods::MCP::Server.send(
1211
+ :start_pipeline_run,
1212
+ kind: :extraction, tool: 'pipeline_extract',
1213
+ task_store: task_store, respond: respond, respond_err: respond_err,
1214
+ runner_builder: build_extraction_runner,
1215
+ started: -> { guard&.record!(:extraction) },
1216
+ started_message: 'Extraction pipeline started in background thread'
1217
+ )
1218
+ end
1219
+ end
952
1220
 
953
- Thread.new do
954
- extractor = Woods::Extractor.new(
955
- output_dir: Woods.configuration.output_dir
956
- )
957
- incremental ? extractor.extract_changed(files) : extractor.extract_all
958
- rescue StandardError => e
959
- logger = defined?(Rails) ? Rails.logger : Logger.new($stderr)
960
- logger.error("[Woods] Pipeline extract failed: #{e.message}")
961
- ensure
962
- Woods::MCP::Server.send(:pipeline_finish, :extraction)
963
- end
1221
+ # The same lock every other writer against this index uses —
1222
+ # `woods:extract`/`incremental`/`refresh` and the watch daemon all
1223
+ # build it from the daemon's constants (see CLAUDE.md, "writers
1224
+ # serialize on PipelineLock").
1225
+ #
1226
+ # @param output_dir [String, Pathname] index directory
1227
+ # @return [Woods::Coordination::PipelineLock]
1228
+ def build_extraction_lock(output_dir)
1229
+ require_relative '../coordination/pipeline_lock'
1230
+ require_relative '../coordination/lock_heartbeat'
1231
+ require_relative '../watch/daemon'
1232
+
1233
+ Woods::Coordination::PipelineLock.new(
1234
+ lock_dir: output_dir.to_s,
1235
+ name: Woods::Watch::Daemon::LOCK_NAME,
1236
+ stale_timeout: Woods::Watch::Daemon::LOCK_STALE_TIMEOUT
1237
+ )
1238
+ end
964
1239
 
965
- respond.call(JSON.pretty_generate({
966
- status: 'started',
967
- message: 'Extraction pipeline started in background thread'
968
- }))
1240
+ # Poll for the lock until {PIPELINE_LOCK_WAIT} elapses. Monotonic, so
1241
+ # a clock adjustment mid-wait cannot stretch or shrink the window.
1242
+ #
1243
+ # @param lock [Woods::Coordination::PipelineLock]
1244
+ # @return [Boolean] whether the lock was acquired
1245
+ def acquire_lock_briefly(lock)
1246
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PIPELINE_LOCK_WAIT
1247
+ acquired = lock.acquire
1248
+ until acquired || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
1249
+ sleep 0.1
1250
+ acquired = lock.acquire
969
1251
  end
1252
+ acquired
970
1253
  end
971
1254
 
972
- def define_pipeline_embed_tool(server, operator, respond, respond_err, op_missing)
1255
+ # Build the cooldown-gate error for pipeline_extract/pipeline_embed,
1256
+ # or nil when the operation may proceed.
1257
+ #
1258
+ # `PipelineGuard#allow?` fails closed on state it cannot verify
1259
+ # (corrupt or permission-denied), which reads identically to a
1260
+ # genuine, elapsing cooldown from the boolean alone. Reporting
1261
+ # `:rate_limited, retry_after_seconds: 300` for state that will
1262
+ # never resolve on its own is the inaccurate public metadata this
1263
+ # closes — `PipelineGuard#state_status` distinguishes why, and the
1264
+ # tool error now says so.
1265
+ #
1266
+ # @param guard [Woods::Operator::PipelineGuard, nil]
1267
+ # @param operation [Symbol] :extraction or :embedding
1268
+ # @param tool [String] tool name, for the error payload
1269
+ # @return [MCP::Tool::Response, nil]
1270
+ def cooldown_error(guard, operation, tool)
1271
+ return nil unless guard
1272
+ return nil if guard.allow?(operation)
1273
+
1274
+ case guard.state_status
1275
+ when :corrupt
1276
+ # `pipeline_repair`'s `reset_cooldowns` action deletes state by
1277
+ # key and cannot act on content it cannot parse, so it will not
1278
+ # clear this — the fix is replacing or removing the state file
1279
+ # directly.
1280
+ error_response(
1281
+ 'Pipeline cooldown state is corrupt, so the cooldown cannot be verified. ' \
1282
+ 'Inspect and replace (or remove) pipeline_guard.json in the operator state directory.',
1283
+ code: :cooldown_state_corrupt, tool: tool
1284
+ )
1285
+ when :permission_denied
1286
+ error_response(
1287
+ 'Pipeline cooldown state is unreadable (permission denied), so the cooldown cannot be verified. ' \
1288
+ 'Check the operator state directory permissions.',
1289
+ code: :cooldown_state_unreadable, tool: tool
1290
+ )
1291
+ else
1292
+ error_response(
1293
+ "#{operation == :extraction ? 'Extraction' : 'Embedding'} is rate-limited. Try again later.",
1294
+ code: :rate_limited, tool: tool, retry_after_seconds: 300
1295
+ )
1296
+ end
1297
+ end
1298
+
1299
+ def define_pipeline_embed_tool(server, operator, respond, respond_err, op_missing, task_store)
1300
+ cooldown = method(:cooldown_error)
973
1301
  server.define_tool(
974
1302
  name: 'pipeline_embed',
975
1303
  description: 'Trigger embedding generation for extracted units. Checks rate limits before proceeding.',
@@ -982,49 +1310,239 @@ module Woods
982
1310
  next op_missing.call('pipeline_embed') unless operator
983
1311
 
984
1312
  guard = operator[:pipeline_guard]
985
- if guard && !guard.allow?(:embedding)
986
- next respond_err.call(
987
- 'Embedding is rate-limited. Try again later.',
988
- code: :rate_limited,
989
- tool: 'pipeline_embed',
990
- retry_after_seconds: 300
991
- )
1313
+ if (blocked = cooldown.call(guard, :embedding, 'pipeline_embed'))
1314
+ next blocked
992
1315
  end
993
1316
 
994
- # Acquire the in-process lock first so a refused "already
995
- # running" request doesn't burn the cooldown clock.
996
- unless Woods::MCP::Server.send(:pipeline_start, :embedding)
997
- next respond_err.call(
998
- 'Embedding pipeline is already running. Wait for it to complete.',
999
- code: :already_running,
1000
- tool: 'pipeline_embed'
1001
- )
1317
+ build_embed_runner = lambda do |_output_dir|
1318
+ lambda do
1319
+ # Share the rake-task wiring so the MCP path picks up the
1320
+ # provider-tuned TextPreparer + token-aware chunker. Without
1321
+ # this, MCP-triggered embedding still hit Ollama's "input
1322
+ # length exceeds context length" error after the rake path
1323
+ # was fixed in PR #70.
1324
+ indexer = Woods::Tasks.build_embed_indexer
1325
+ incremental ? indexer.index_incremental : indexer.index_all
1326
+ end
1002
1327
  end
1003
1328
 
1004
- guard&.record!(:embedding)
1329
+ next Woods::MCP::Server.send(
1330
+ :start_pipeline_run,
1331
+ kind: :embedding, tool: 'pipeline_embed',
1332
+ task_store: task_store, respond: respond, respond_err: respond_err,
1333
+ runner_builder: build_embed_runner,
1334
+ started: -> { guard&.record!(:embedding) },
1335
+ started_message: 'Embedding pipeline started in background thread'
1336
+ )
1337
+ end
1338
+ end
1339
+
1340
+ # Claim the in-process pipeline slot, take the cross-process writer
1341
+ # lock, and hand the run off to a background thread.
1342
+ #
1343
+ # Owns the entire window between {pipeline_start} and the hand-off.
1344
+ # Before this existed, ANY raise inside that window skipped
1345
+ # {pipeline_finish} for the life of the process, so every later call
1346
+ # answered `already_running` and only a restart cleared it (MCP-3).
1347
+ # `PipelineLock#acquire` rescues only `Errno::EEXIST` and opens/creates
1348
+ # files, so an index directory this process cannot write — the
1349
+ # documented Docker deployment mounts the index into a host-side
1350
+ # server — raises `SystemCallError` out of {acquire_lock_briefly}
1351
+ # rather than returning false. That escaping error also reached
1352
+ # {ToolContract} as a nested SystemCallError and came back as
1353
+ # `corrupt_artifact` ("An Index artifact is unavailable or malformed"),
1354
+ # a misdiagnosis of a permissions failure. Both are closed here, with
1355
+ # the same handoff-flag shape {run_pipeline_in_background} uses one
1356
+ # layer down (L6).
1357
+ #
1358
+ # Cross-PROCESS serialization (#170): {pipeline_start} only guards this
1359
+ # process; the rake writers and the watch daemon serialize on the
1360
+ # on-disk PipelineLock, and an unlocked MCP run was free to rewrite the
1361
+ # index under any of them with the loser's work silently discarded. The
1362
+ # wait is short and the failure explicit — an MCP tool must not sit on
1363
+ # a lock queue for minutes the way `woods:extract` does.
1364
+ #
1365
+ # A nil/duck-typed configuration (no output_dir) yields no lock: there
1366
+ # is no known lock domain, and the run fails in the background exactly
1367
+ # as it always has on an unconfigured host.
1368
+ #
1369
+ # @param kind [Symbol] :extraction or :embedding
1370
+ # @param tool [String] tool name, for the error payloads
1371
+ # @param runner_builder [Proc] called with the resolved output_dir;
1372
+ # returns the runner lambda {run_pipeline_in_background} executes
1373
+ # @return [Hash, MCP::Tool::Response]
1374
+ def start_pipeline_run(kind:, tool:, task_store:, respond:, respond_err:, runner_builder:, started:,
1375
+ started_message:)
1376
+ # Claim the slot BEFORE recording to the guard. Otherwise a refused
1377
+ # "already running" request still resets the cooldown clock and
1378
+ # blocks the next legitimate attempt for the full window.
1379
+ unless pipeline_start(kind)
1380
+ return respond_err.call(
1381
+ "#{PIPELINE_LABELS.fetch(kind)} pipeline is already running. Wait for it to complete.",
1382
+ code: :already_running,
1383
+ tool: tool
1384
+ )
1385
+ end
1386
+
1387
+ handed_off = false
1388
+ held_lock = nil
1389
+ begin
1390
+ config = Woods.configuration
1391
+ output_dir = config.output_dir if config.respond_to?(:output_dir)
1392
+ lock = output_dir && build_extraction_lock(output_dir)
1393
+ if lock
1394
+ unless acquire_lock_briefly(lock)
1395
+ return respond_err.call(
1396
+ 'Another writer holds the extraction lock (a rake task or the watch daemon ' \
1397
+ 'is writing this index). Try again shortly.',
1398
+ code: :locked,
1399
+ tool: tool
1400
+ )
1401
+ end
1402
+
1403
+ held_lock = lock
1404
+ end
1405
+
1406
+ # Built before the flag flips: a runner_builder that raises must
1407
+ # still release the lock and the slot on the way out.
1408
+ runner = runner_builder.call(output_dir)
1409
+
1410
+ # From here the background hand-off owns both the on-disk lock and
1411
+ # the in-process slot: run_pipeline_in_background releases them on
1412
+ # every one of its own paths.
1413
+ handed_off = true
1414
+ run_pipeline_in_background(
1415
+ kind: kind, tool: tool, lock: lock, task_store: task_store,
1416
+ respond: respond, respond_err: respond_err, runner: runner,
1417
+ started: started, started_message: started_message
1418
+ )
1419
+ rescue SystemCallError => e
1420
+ respond_err.call(
1421
+ 'The index directory\'s writer lock could not be taken — this is a filesystem ' \
1422
+ "permissions problem, not a corrupt index (#{e.class}: #{e.message}). Make the index " \
1423
+ 'directory writable by this process, then invoke the tool again.',
1424
+ code: :lock_unwritable,
1425
+ tool: tool,
1426
+ exception: e.class.name
1427
+ )
1428
+ ensure
1429
+ unless handed_off
1430
+ held_lock&.release
1431
+ pipeline_finish(kind)
1432
+ end
1433
+ end
1434
+ end
1435
+
1436
+ # Run a pipeline on a background thread and answer the caller.
1437
+ #
1438
+ # Both pipeline tools reached this point with the same shape: a lock
1439
+ # they may or may not hold, a runner lambda, and the need to answer
1440
+ # immediately because a full run takes minutes. What differs now is
1441
+ # *how* they answer.
1442
+ #
1443
+ # When the client declared the Tasks extension, the answer is a durable
1444
+ # `CreateTaskResult`. That is the whole point of the extension here: the
1445
+ # handle outlives this process, so a client that disconnects mid-run can
1446
+ # reconnect and still learn whether extraction succeeded — and if the
1447
+ # process dies, {Tasks::Store} resolves the orphaned record to `failed`
1448
+ # instead of leaving an agent polling a run that no longer exists.
1449
+ #
1450
+ # When it did not, the answer is exactly what it always was. The spec is
1451
+ # explicit that a task must never go to a client that did not opt in:
1452
+ # such a client would read the handle as the final result and report a
1453
+ # completed run that had not started.
1454
+ #
1455
+ # @param kind [Symbol] :extraction or :embedding, for the in-process lock
1456
+ # @param tool [String] tool name, for the task record and error text
1457
+ # @param lock [Woods::Coordination::PipelineLock, nil]
1458
+ # @param task_store [Tasks::Store, nil] nil disables the task path
1459
+ # @param respond [Method] the text-response builder
1460
+ # @param runner [Proc] the actual work
1461
+ # @param started_message [String] legacy fire-and-forget message
1462
+ # @return [Hash, MCP::Tool::Response]
1463
+ def run_pipeline_in_background(kind:, tool:, lock:, task_store:, respond:, respond_err:, runner:, started:,
1464
+ started_message:)
1465
+ # Ownership of the on-disk lock transfers to the background thread
1466
+ # the moment Thread.new succeeds — its ensure releases it when the
1467
+ # run finishes. Before that point (guard failures, started hook),
1468
+ # THIS method owns the lock and must release it on the way out
1469
+ # (L6): the rescue below covers only SystemCallError/IOError, so a
1470
+ # guard bug raising anything else used to leak the on-disk lock
1471
+ # and block every later writer until the stale window expired.
1472
+ handoff = false
1473
+ begin
1474
+ task = create_pipeline_task(task_store, tool)
1475
+ started.call
1005
1476
 
1006
1477
  Thread.new do
1007
- # Share the rake-task wiring so the MCP path picks up the
1008
- # provider-tuned TextPreparer + token-aware chunker. Without
1009
- # this, MCP-triggered embedding still hit Ollama's "input
1010
- # length exceeds context length" error after the rake path
1011
- # was fixed in PR #70.
1012
- indexer = Woods::Tasks.build_embed_indexer
1013
- incremental ? indexer.index_incremental : indexer.index_all
1014
- rescue StandardError => e
1478
+ # Heartbeat, like the rake writers: a full run on a large host can
1479
+ # outlive the lock's stale window, and an untouched lock would be
1480
+ # retired by the next contender mid-run — recreating the two-writer
1481
+ # clobber.
1482
+ if lock
1483
+ Woods::Coordination::LockHeartbeat.run(lock) { runner.call }
1484
+ else
1485
+ runner.call
1486
+ end
1487
+ task_store&.complete!(task.id, result: pipeline_task_result(tool)) if task
1488
+ rescue StandardError, ScriptError => e
1489
+ # ScriptError (SyntaxError, LoadError) is not a StandardError, and
1490
+ # +runner.call+ can lazily +require_relative+ the extractor — a
1491
+ # half-typed file must degrade this background thread, not kill it
1492
+ # silently while the task record sits at "working" until pid-death
1493
+ # (see the "rescue ScriptError anywhere a reload can happen" rule).
1015
1494
  logger = defined?(Rails) ? Rails.logger : Logger.new($stderr)
1016
- logger.error("[Woods] Pipeline embed failed: #{e.message}")
1495
+ logger.error("[Woods] Pipeline #{kind} failed: #{e.message}")
1496
+ # Recording the failure is the half that was missing: previously the
1497
+ # error reached a log the agent cannot read, and the tool had
1498
+ # already reported success.
1499
+ task_store&.fail!(task.id, message: "#{e.class}: #{e.message}") if task
1017
1500
  ensure
1018
- Woods::MCP::Server.send(:pipeline_finish, :embedding)
1501
+ lock&.release
1502
+ Woods::MCP::Server.send(:pipeline_finish, kind)
1019
1503
  end
1504
+ handoff = true
1020
1505
 
1021
- respond.call(JSON.pretty_generate({
1022
- status: 'started',
1023
- message: 'Embedding pipeline started in background thread'
1024
- }))
1506
+ return Tasks::Extension.create_task_result(task) if task
1507
+
1508
+ respond.call(JSON.pretty_generate({ status: 'started', message: started_message }))
1509
+ rescue SystemCallError, IOError => e
1510
+ respond_err.call(
1511
+ 'The task could not be durably recorded, so the pipeline was not started.',
1512
+ code: :task_store_unavailable,
1513
+ tool: tool,
1514
+ exception: e.class.name
1515
+ )
1516
+ ensure
1517
+ unless handoff
1518
+ lock&.release
1519
+ Woods::MCP::Server.send(:pipeline_finish, kind)
1520
+ end
1025
1521
  end
1026
1522
  end
1027
1523
 
1524
+ # Mint a task record, or return nil to take the legacy path.
1525
+ #
1526
+ # Nil only when the client did not opt in. Once a client opts in, task
1527
+ # durability is part of the response contract; a write failure propagates
1528
+ # to {run_pipeline_in_background}, which fails closed before work starts.
1529
+ #
1530
+ # @return [Tasks::Store::Task, nil]
1531
+ def create_pipeline_task(task_store, tool)
1532
+ return nil unless task_store && Tasks::RequestCapture.tasks_requested?
1533
+
1534
+ task_store.create!(tool: tool)
1535
+ end
1536
+
1537
+ # What `tasks/get` hands back on success — shaped like the synchronous
1538
+ # tool result the caller would have received had it waited.
1539
+ def pipeline_task_result(tool)
1540
+ {
1541
+ 'content' => [{ 'type' => 'text', 'text' => "#{tool} completed successfully." }],
1542
+ 'isError' => false
1543
+ }
1544
+ end
1545
+
1028
1546
  # Acquire a pipeline-kind lock atomically. Returns false when
1029
1547
  # another thread is already running that kind of pipeline (so the
1030
1548
  # caller can refuse the new request instead of racing the running
@@ -1124,8 +1642,21 @@ module Woods
1124
1642
  when 'clear_locks'
1125
1643
  lock = operator[:pipeline_lock]
1126
1644
  if lock
1127
- lock.release
1128
- respond.call(JSON.pretty_generate({ repaired: true, action: 'clear_locks' }))
1645
+ outcome = lock.retire_stale
1646
+ case outcome
1647
+ when :cleared
1648
+ respond.call(JSON.pretty_generate({ repaired: true, action: 'clear_locks', outcome: 'cleared' }))
1649
+ when :missing
1650
+ respond_err.call(
1651
+ 'No pipeline lock exists; nothing was repaired.',
1652
+ code: :lock_missing, tool: 'pipeline_repair', action: action, repaired: false
1653
+ )
1654
+ when :not_stale
1655
+ respond_err.call(
1656
+ 'The pipeline lock is active and was not cleared.',
1657
+ code: :lock_active, tool: 'pipeline_repair', action: action, repaired: false
1658
+ )
1659
+ end
1129
1660
  else
1130
1661
  respond_err.call(
1131
1662
  'Pipeline lock is not configured.',
@@ -1135,7 +1666,22 @@ module Woods
1135
1666
  )
1136
1667
  end
1137
1668
  when 'reset_cooldowns'
1138
- respond.call(JSON.pretty_generate({ repaired: true, action: 'reset_cooldowns' }))
1669
+ guard = operator[:pipeline_guard]
1670
+ if guard.nil?
1671
+ respond_err.call(
1672
+ 'Pipeline guard is not configured.',
1673
+ code: :not_configured,
1674
+ config_key: 'operator.pipeline_guard',
1675
+ tool: 'pipeline_repair'
1676
+ )
1677
+ elsif guard.reset!(:all)
1678
+ respond.call(JSON.pretty_generate({ repaired: true, action: action, outcome: 'reset' }))
1679
+ else
1680
+ respond_err.call(
1681
+ 'No pipeline cooldown state exists; nothing was repaired.',
1682
+ code: :cooldown_state_missing, tool: 'pipeline_repair', action: action, repaired: false
1683
+ )
1684
+ end
1139
1685
  else
1140
1686
  respond_err.call(
1141
1687
  "Unknown repair action: #{action}",
@@ -1359,7 +1905,7 @@ module Woods
1359
1905
  'notion_api_token is not configured. Set it in Woods.configure or via the NOTION_API_TOKEN env var.',
1360
1906
  code: :not_configured,
1361
1907
  config_key: 'notion_api_token',
1362
- doc_link: 'docs/NOTION_EXPORT.md',
1908
+ doc_link: 'docs/NOTION_INTEGRATION.md',
1363
1909
  tool: 'notion_sync'
1364
1910
  )
1365
1911
  end
@@ -1369,7 +1915,7 @@ module Woods
1369
1915
  'notion_database_ids is not configured. Set it in Woods.configure.',
1370
1916
  code: :not_configured,
1371
1917
  config_key: 'notion_database_ids',
1372
- doc_link: 'docs/NOTION_EXPORT.md',
1918
+ doc_link: 'docs/NOTION_INTEGRATION.md',
1373
1919
  tool: 'notion_sync'
1374
1920
  )
1375
1921
  end
@@ -1459,6 +2005,20 @@ module Woods
1459
2005
  # status claiming +embedding_model: "text-embedding-3-small"+ next to
1460
2006
  # +embedding_provider: "ollama"+ and reasonably distrust every field.
1461
2007
  def build_status(reader:, retriever:, index_dir:, bootstrap_state: nil)
2008
+ # Pin the generation across the whole payload. Without this the
2009
+ # manifest can be read at generation N and `generation_fields` then
2010
+ # report N+1 — a status report that describes counts from one index
2011
+ # while announcing the number of another, which is precisely the
2012
+ # confusion this tool exists to resolve.
2013
+ return build_status_payload(reader, retriever, index_dir, bootstrap_state) unless
2014
+ reader.respond_to?(:with_pinned_generation)
2015
+
2016
+ reader.with_pinned_generation do
2017
+ build_status_payload(reader, retriever, index_dir, bootstrap_state)
2018
+ end
2019
+ end
2020
+
2021
+ def build_status_payload(reader, retriever, index_dir, bootstrap_state)
1462
2022
  manifest = safe_manifest(reader)
1463
2023
  extracted_at = manifest && manifest['extracted_at']
1464
2024
  staleness = staleness_seconds(extracted_at)
@@ -1476,7 +2036,8 @@ module Woods
1476
2036
  index_dir: index_dir.to_s,
1477
2037
  update: Woods::UpdateCheck.status_hash
1478
2038
  },
1479
- index: index_section(manifest, extracted_at, staleness, index_dir),
2039
+ index: index_section(manifest, extracted_at, staleness, index_dir, reader),
2040
+ watch: watch_section(index_dir),
1480
2041
  retriever: {
1481
2042
  configured: !retriever.nil?,
1482
2043
  class: retriever&.class&.name
@@ -1505,7 +2066,7 @@ module Woods
1505
2066
  # diff directly. This is an observability signal, not a hard gate —
1506
2067
  # hard-refusing responses would be much more disruptive than a loudly-
1507
2068
  # visible staleness flag that agents can branch on.
1508
- def index_section(manifest, extracted_at, staleness, index_dir)
2069
+ def index_section(manifest, extracted_at, staleness, index_dir, reader = nil)
1509
2070
  base = {
1510
2071
  extracted_at: extracted_at,
1511
2072
  staleness_seconds: staleness,
@@ -1519,6 +2080,9 @@ module Woods
1519
2080
  schema_sha: manifest && manifest['schema_sha']
1520
2081
  }
1521
2082
 
2083
+ base.merge!(generation_fields(index_dir, reader))
2084
+ base.merge!(working_tree_fields(index_dir))
2085
+
1522
2086
  manifest_sha = manifest && manifest['git_sha']
1523
2087
  head_sha = manifest_sha ? resolve_head_sha(index_dir) : nil
1524
2088
  return base unless head_sha
@@ -1528,20 +2092,156 @@ module Woods
1528
2092
  base
1529
2093
  end
1530
2094
 
2095
+ # The generation the index is published at.
2096
+ #
2097
+ # Every extraction mode that writes the *unit* index bumps this as its
2098
+ # last write, so it answers "has the index moved?" without comparing
2099
+ # timestamps — which `staleness_seconds` can't, since it measures
2100
+ # wall-clock age rather than whether anything changed.
2101
+ #
2102
+ # One carve-out: `woods:extract_framework` writes only `rails_source/`
2103
+ # and does not bump, so a framework re-extraction leaves this number
2104
+ # where it was. That is deliberate — framework sources are pinned by the
2105
+ # `Gemfile.lock`, reported separately above, and treating them as an
2106
+ # index generation would invalidate every reader's cache for data that
2107
+ # changes when dependencies do, not when the app does.
2108
+ #
2109
+ # @return [Hash]
2110
+ def generation_fields(index_dir, reader = nil)
2111
+ return {} unless index_dir
2112
+
2113
+ marker = Woods::Generation.new(output_dir: index_dir).current
2114
+ return { generation: nil } if marker.number.zero?
2115
+
2116
+ fields = { generation: marker.number,
2117
+ generation_updated_at: marker.updated_at,
2118
+ generation_reason: marker.reason }
2119
+ fields.merge(served_generation_fields(marker, reader))
2120
+ rescue StandardError
2121
+ {}
2122
+ end
2123
+
2124
+ # What the *reader* is actually serving, which is not always what is
2125
+ # published.
2126
+ #
2127
+ # `build_status` pins the reader so the manifest and counts above come
2128
+ # from one generation, but this method reads `generation.json` from
2129
+ # disk — so a publish landing mid-call would otherwise report a
2130
+ # generation number beside counts from the previous one, the exact
2131
+ # mismatch the pin is there to remove. When they differ, say so instead
2132
+ # of quietly picking one.
2133
+ def served_generation_fields(marker, reader)
2134
+ return {} unless reader.respond_to?(:loaded_generation)
2135
+
2136
+ served = reader.loaded_generation
2137
+ return {} if served.nil? || served == marker.number
2138
+
2139
+ { served_generation: served, generation_lag: marker.number - served }
2140
+ rescue StandardError
2141
+ {}
2142
+ end
2143
+
2144
+ # Whether the working tree has uncommitted changes, and a fingerprint
2145
+ # of them.
2146
+ #
2147
+ # `git_sha_matches_head` only sees *committed* HEAD, so an agent
2148
+ # working through forty uncommitted edits could be told the index
2149
+ # matches HEAD while every answer described the tree before those
2150
+ # edits. `working_tree_dirty` is the fix for that.
2151
+ #
2152
+ # The fingerprint is a digest of `git status --porcelain` *as of this
2153
+ # call*. Nothing records the digest the index was built at, so it does
2154
+ # not answer "is this the same dirty state the index describes" — it
2155
+ # gives a caller a stable identity for the current dirty state, so two
2156
+ # of its own calls can be compared to detect the tree moving underneath
2157
+ # it. Pair it with `generation` to tell "tree changed, index followed"
2158
+ # from "tree changed, index has not caught up".
2159
+ #
2160
+ # @return [Hash]
2161
+ def working_tree_fields(index_dir)
2162
+ porcelain = resolve_working_tree_status(index_dir)
2163
+ return {} if porcelain.nil?
2164
+
2165
+ { working_tree_dirty: !porcelain.empty?,
2166
+ working_tree_fingerprint: Digest::SHA256.hexdigest(porcelain)[0, 16] }
2167
+ end
2168
+
2169
+ # `git status --porcelain` for the repo containing +index_dir+, or nil
2170
+ # when that can't be answered.
2171
+ #
2172
+ # capture3, not capture2e: stderr still must not reach the stdio
2173
+ # transport, but folding it into stdout makes any warning git emits on a
2174
+ # successful run — a stale index.lock notice, a detached-HEAD advisory,
2175
+ # `core.fsmonitor` chatter — part of the "porcelain" output. A clean tree
2176
+ # then reports dirty, and the fingerprint changes with the warning rather
2177
+ # than with the code.
2178
+ def resolve_working_tree_status(index_dir)
2179
+ return nil unless index_dir
2180
+
2181
+ dir = index_dir.to_s
2182
+ return nil unless File.directory?(dir)
2183
+
2184
+ output, _stderr, status = Open3.capture3('git', '-C', dir, 'status', '--porcelain')
2185
+ status.success? ? output : nil
2186
+ rescue StandardError
2187
+ nil
2188
+ end
2189
+
2190
+ # The watch daemon's state, so an agent can branch on whether anything
2191
+ # is keeping this index current.
2192
+ #
2193
+ # Three states matter and they are not interchangeable: `running`
2194
+ # (current, or current within a debounce window), `degraded` (alive but
2195
+ # unable to update — the reason says why, and the index is frozen at a
2196
+ # known generation), and `stopped`/`absent` (nothing is maintaining
2197
+ # this index; fall back to whatever the last explicit run left).
2198
+ #
2199
+ # @return [Hash]
2200
+ def watch_section(index_dir)
2201
+ return { state: 'absent' } unless index_dir
2202
+
2203
+ path = File.join(index_dir.to_s, Woods::Watch::Status::FILENAME)
2204
+ return { state: 'absent' } unless File.exist?(path)
2205
+
2206
+ # AtomicFile.read: the daemon's reasons contain em dashes, and a
2207
+ # US-ASCII default external encoding turns a plain File.read of them
2208
+ # into an Encoding::InvalidByteSequenceError — raising out of
2209
+ # woods_status entirely rather than degrading it.
2210
+ record = JSON.parse(Woods::AtomicFile.read(path))
2211
+ # `state` is whatever the daemon last wrote, and a `kill -9`'d daemon
2212
+ # leaves `running` behind forever. `alive?` adds the two checks that
2213
+ # catch that — the pid still exists and the record is recent — so the
2214
+ # payload can distinguish "maintaining this index" from "claimed to be,
2215
+ # once". Reported as a separate field rather than by overwriting
2216
+ # `state`, because the recorded state and the liveness verdict answer
2217
+ # different questions and an operator wants both.
2218
+ status = Woods::Watch::Status.new(output_dir: index_dir)
2219
+ { state: record['state'], reason: record['reason'], generation: record['generation'],
2220
+ pid: record['pid'], updated_at: record['updated_at'],
2221
+ alive: status.alive?, stale_after_seconds: Woods::Watch::Status::STALE_AFTER,
2222
+ last_action: record['last_action'], last_duration_ms: record['last_duration_ms'] }
2223
+ rescue StandardError
2224
+ { state: 'absent' }
2225
+ end
2226
+
1531
2227
  # Resolve the current HEAD SHA for the git repo containing +index_dir+.
1532
2228
  # Returns nil when git is unavailable or +index_dir+ is not in a repo —
1533
2229
  # callers treat nil as "can't compare" rather than "mismatch".
1534
2230
  #
1535
- # Uses +capture2e+ so git's "fatal: not a git repository" stderr banner
1536
- # does not leak through the MCP stdio transport. MCP clients that parse
1537
- # stderr for protocol framing can't tolerate stray lines.
2231
+ # capture3 keeps git's stderr out of the MCP stdio transport — clients
2232
+ # that parse stderr for protocol framing can't tolerate stray lines —
2233
+ # *and* out of the SHA. capture2e folded them together, so a warning on
2234
+ # an otherwise successful `rev-parse` (a stale `index.lock` notice, a
2235
+ # `core.fsmonitor` complaint) was concatenated into the value this
2236
+ # method returns and compared against the manifest as if it were a SHA.
2237
+ # Same hazard as {#resolve_working_tree_status}, one probe over.
1538
2238
  def resolve_head_sha(index_dir)
1539
2239
  return nil unless index_dir
1540
2240
 
1541
2241
  dir = index_dir.to_s
1542
2242
  return nil unless File.directory?(dir)
1543
2243
 
1544
- output, status = Open3.capture2e('git', '-C', dir, 'rev-parse', 'HEAD')
2244
+ output, _stderr, status = Open3.capture3('git', '-C', dir, 'rev-parse', 'HEAD')
1545
2245
  status.success? ? output.strip : nil
1546
2246
  rescue Errno::ENOENT, Errno::EACCES
1547
2247
  # git not installed or not executable on this host — equivalent to
@@ -1621,28 +2321,74 @@ module Woods
1621
2321
  def register_resource_handler(server, reader)
1622
2322
  server.resources_read_handler do |params|
1623
2323
  uri = params[:uri]
1624
- case uri
1625
- when 'codebase://manifest'
1626
- [{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(reader.manifest) }]
1627
- when 'codebase://graph'
1628
- [{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(reader.raw_graph_data) }]
1629
- when %r{\Acodebase://unit/(.+)\z}
1630
- identifier = Regexp.last_match(1)
1631
- unit = reader.find_unit(identifier)
1632
- if unit
1633
- [{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(unit) }]
1634
- else
1635
- [{ uri: uri, mimeType: 'text/plain', text: "Unit not found: #{identifier}" }]
1636
- end
1637
- when %r{\Acodebase://type/(.+)\z}
1638
- type = Regexp.last_match(1)
1639
- units = reader.list_units(type: type)
1640
- [{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(units) }]
1641
- else
1642
- [{ uri: uri, mimeType: 'text/plain', text: "Unknown resource: #{uri}" }]
2324
+ kind, target = parse_resource_uri(uri)
2325
+ raise ::MCP::Server::ResourceNotFoundError.new(uri, params) unless kind
2326
+
2327
+ payload = resource_payload(reader, kind, target)
2328
+ raise ::MCP::Server::ResourceNotFoundError.new(uri, params) if payload.nil?
2329
+
2330
+ [{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(payload) }]
2331
+ rescue ::MCP::Server::ResourceNotFoundError
2332
+ raise
2333
+ rescue JSON::ParserError, SystemCallError, IOError, TypeError => e
2334
+ raise corrupt_resource_error(uri, params, e)
2335
+ end
2336
+ end
2337
+
2338
+ def parse_resource_uri(uri)
2339
+ return [:manifest, nil] if uri == 'codebase://manifest'
2340
+ return [:graph, nil] if uri == 'codebase://graph'
2341
+ return unless uri.is_a?(String)
2342
+
2343
+ parsed = URI.parse(uri)
2344
+ return unless parsed.scheme == 'codebase'
2345
+ return unless %w[unit type].include?(parsed.host)
2346
+ return if parsed.userinfo || parsed.port || parsed.query || parsed.fragment || parsed.opaque
2347
+
2348
+ raw_target = parsed.path.to_s.delete_prefix('/')
2349
+ return if raw_target.empty? || raw_target.include?('/')
2350
+
2351
+ target = URI::DEFAULT_PARSER.unescape(raw_target).force_encoding(Encoding::UTF_8)
2352
+ return unless target.valid_encoding?
2353
+ return if target.match?(%r{[%\\/\x00-\x1f\x7f]})
2354
+ return if %w[. ..].include?(target)
2355
+
2356
+ [parsed.host.to_sym, target]
2357
+ rescue URI::InvalidURIError
2358
+ nil
2359
+ end
2360
+
2361
+ def resource_payload(reader, kind, target)
2362
+ case kind
2363
+ when :manifest
2364
+ reader.manifest.tap { |value| raise TypeError unless value.is_a?(Hash) }
2365
+ when :graph
2366
+ reader.raw_graph_data.tap do |value|
2367
+ raise TypeError unless value.is_a?(Hash) && value['nodes'].is_a?(Hash) && value['edges'].is_a?(Hash)
2368
+ end
2369
+ when :unit
2370
+ reader.find_unit(target).tap do |value|
2371
+ raise TypeError if value && (!value.is_a?(Hash) || value['identifier'] != target)
2372
+ end
2373
+ when :type
2374
+ return nil unless IndexReader::TYPE_TO_DIR.key?(target)
2375
+
2376
+ reader.list_units(type: target).tap do |value|
2377
+ raise TypeError unless value.is_a?(Array) && value.all?(Hash)
1643
2378
  end
1644
2379
  end
1645
2380
  end
2381
+
2382
+ def corrupt_resource_error(uri, params, original_error)
2383
+ ::MCP::Server::RequestHandlerError.new(
2384
+ 'Resource artifact is unavailable or malformed.',
2385
+ params,
2386
+ error_type: :internal_error,
2387
+ original_error: original_error,
2388
+ error_code: ::JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
2389
+ error_data: { uri: uri, error_code: 'corrupt_artifact' }
2390
+ )
2391
+ end
1646
2392
  end
1647
2393
  end
1648
2394
  end