woods 2.0.0.beta2 → 2.0.0.beta3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +262 -1
  3. data/CONTRIBUTING.md +173 -9
  4. data/README.md +7 -3
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +83 -4
  7. data/docs/AGENT_SETUP.md +82 -1
  8. data/docs/BACKEND_MATRIX.md +20 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +199 -14
  11. data/docs/CONSOLE_MCP_SETUP.md +35 -5
  12. data/docs/DOCKER_SETUP.md +21 -2
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +36 -5
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +117 -1
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +221 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +33 -18
  22. data/docs/NOTION_INTEGRATION.md +13 -0
  23. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  24. data/docs/PUBLISHED_INDEX.md +55 -0
  25. data/docs/README.md +7 -0
  26. data/docs/RETRIEVAL_GUIDE.md +253 -11
  27. data/docs/RUNTIME_TRACING.md +71 -0
  28. data/docs/SOURCE_FRESHNESS.md +143 -0
  29. data/docs/TROUBLESHOOTING.md +117 -5
  30. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  31. data/docs/UPGRADING_TO_2.md +44 -22
  32. data/docs/WATCH_DAEMON.md +259 -59
  33. data/exe/woods-agent-config +6 -0
  34. data/exe/woods-extract +5 -0
  35. data/exe/woods-hook-context +6 -0
  36. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  37. data/lib/tasks/woods.rake +47 -397
  38. data/lib/woods/agent_configuration/applier.rb +133 -0
  39. data/lib/woods/agent_configuration/cli.rb +101 -0
  40. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  41. data/lib/woods/agent_configuration/document.rb +105 -0
  42. data/lib/woods/agent_configuration/error.rb +7 -0
  43. data/lib/woods/agent_configuration/launcher.rb +75 -0
  44. data/lib/woods/agent_configuration/layout.rb +59 -0
  45. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  46. data/lib/woods/agent_configuration/plan.rb +98 -0
  47. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  48. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  49. data/lib/woods/agent_configuration/planner.rb +63 -0
  50. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  51. data/lib/woods/agent_configuration/preflight.rb +100 -0
  52. data/lib/woods/agent_configuration/recovery.rb +49 -0
  53. data/lib/woods/ast/node.rb +2 -0
  54. data/lib/woods/ast/parser.rb +38 -5
  55. data/lib/woods/builder.rb +21 -5
  56. data/lib/woods/cache/cache_middleware.rb +28 -7
  57. data/lib/woods/cache/cache_store.rb +4 -5
  58. data/lib/woods/change_set.rb +5 -4
  59. data/lib/woods/console/credential_index.rb +20 -2
  60. data/lib/woods/console/credential_scanner.rb +14 -14
  61. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  62. data/lib/woods/console/embedded_executor.rb +1 -1
  63. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  64. data/lib/woods/console/rack_middleware.rb +22 -13
  65. data/lib/woods/console/server.rb +18 -16
  66. data/lib/woods/dependency_graph.rb +65 -13
  67. data/lib/woods/embedding/corpus.rb +94 -0
  68. data/lib/woods/embedding/indexer.rb +90 -46
  69. data/lib/woods/embedding/openai.rb +17 -6
  70. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  71. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  72. data/lib/woods/export/typed_reader.rb +56 -0
  73. data/lib/woods/extractor.rb +232 -137
  74. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  75. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  76. data/lib/woods/extractors/caching_extractor.rb +3 -1
  77. data/lib/woods/extractors/concern_extractor.rb +64 -6
  78. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  79. data/lib/woods/extractors/controller_extractor.rb +13 -4
  80. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  81. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  82. data/lib/woods/extractors/engine_extractor.rb +3 -1
  83. data/lib/woods/extractors/event_extractor.rb +4 -2
  84. data/lib/woods/extractors/factory_extractor.rb +3 -1
  85. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  86. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  87. data/lib/woods/extractors/job_extractor.rb +6 -19
  88. data/lib/woods/extractors/lib_extractor.rb +3 -1
  89. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  90. data/lib/woods/extractors/manager_extractor.rb +3 -1
  91. data/lib/woods/extractors/method_parameters.rb +53 -0
  92. data/lib/woods/extractors/middleware_argument.rb +65 -0
  93. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  94. data/lib/woods/extractors/migration_extractor.rb +3 -1
  95. data/lib/woods/extractors/model_extractor.rb +39 -33
  96. data/lib/woods/extractors/package_extractor.rb +24 -4
  97. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  98. data/lib/woods/extractors/policy_extractor.rb +3 -1
  99. data/lib/woods/extractors/poro_extractor.rb +3 -1
  100. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  101. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  102. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  103. data/lib/woods/extractors/route_extractor.rb +3 -1
  104. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  105. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  106. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  107. data/lib/woods/extractors/service_extractor.rb +3 -1
  108. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  109. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  110. data/lib/woods/extractors/source_nesting.rb +1 -1
  111. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  112. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  113. data/lib/woods/extractors/validator_extractor.rb +3 -1
  114. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  116. data/lib/woods/gem_mapper.rb +2 -0
  117. data/lib/woods/git_history.rb +116 -0
  118. data/lib/woods/graph_analyzer.rb +35 -6
  119. data/lib/woods/hooks/context_cli.rb +54 -0
  120. data/lib/woods/hooks/context_event.rb +88 -0
  121. data/lib/woods/hooks/context_hint.rb +73 -0
  122. data/lib/woods/hooks/context_impact.rb +77 -0
  123. data/lib/woods/hooks/context_output.rb +47 -0
  124. data/lib/woods/hooks/context_state.rb +102 -0
  125. data/lib/woods/hooks/refresh.rb +79 -0
  126. data/lib/woods/hooks/rule_projection.rb +78 -0
  127. data/lib/woods/input_rules.rb +19 -0
  128. data/lib/woods/mcp/bearer_auth.rb +20 -12
  129. data/lib/woods/mcp/bootstrapper.rb +62 -0
  130. data/lib/woods/mcp/index_reader.rb +323 -160
  131. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  132. data/lib/woods/mcp/origin_guard.rb +17 -9
  133. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  134. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  135. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  136. data/lib/woods/mcp/search_results.rb +74 -0
  137. data/lib/woods/mcp/server.rb +158 -37
  138. data/lib/woods/mcp/tool_contract.rb +2 -0
  139. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  140. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  141. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  142. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  143. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  144. data/lib/woods/notion/exporter.rb +56 -17
  145. data/lib/woods/obsidian/destination_plan.rb +98 -0
  146. data/lib/woods/obsidian/name_mapper.rb +19 -3
  147. data/lib/woods/obsidian/note_builder.rb +19 -10
  148. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  149. data/lib/woods/operator/pipeline_guard.rb +18 -13
  150. data/lib/woods/path_dispatcher.rb +7 -1
  151. data/lib/woods/payload_store.rb +27 -26
  152. data/lib/woods/railtie.rb +3 -3
  153. data/lib/woods/railtie_support.rb +12 -12
  154. data/lib/woods/rake_helpers.rb +392 -0
  155. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  156. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  157. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  158. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  159. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  160. data/lib/woods/resilience/index_validator.rb +112 -23
  161. data/lib/woods/retrieval/context_assembler.rb +50 -15
  162. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  163. data/lib/woods/retrieval/lexical_index.rb +119 -0
  164. data/lib/woods/retrieval/ranker.rb +4 -2
  165. data/lib/woods/retrieval/scope.rb +108 -0
  166. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  167. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  168. data/lib/woods/retrieval/search_executor.rb +86 -27
  169. data/lib/woods/retrieval/source_evidence.rb +200 -0
  170. data/lib/woods/retriever.rb +98 -22
  171. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  172. data/lib/woods/session_tracer/middleware.rb +10 -12
  173. data/lib/woods/session_tracer/redis_store.rb +22 -6
  174. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  175. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  176. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  177. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  178. data/lib/woods/source_inputs/handoff.rb +102 -0
  179. data/lib/woods/source_inputs/launcher.rb +157 -0
  180. data/lib/woods/source_inputs/manifest.rb +124 -0
  181. data/lib/woods/source_inputs/private_key.rb +55 -0
  182. data/lib/woods/source_inputs/scanner.rb +171 -0
  183. data/lib/woods/source_inputs/scopes.rb +71 -0
  184. data/lib/woods/source_inputs/session.rb +214 -0
  185. data/lib/woods/source_inputs/status.rb +84 -0
  186. data/lib/woods/source_inputs/verifier.rb +107 -0
  187. data/lib/woods/storage/metadata_store.rb +25 -25
  188. data/lib/woods/storage/pgvector.rb +29 -8
  189. data/lib/woods/storage/qdrant.rb +17 -7
  190. data/lib/woods/storage/vector_store.rb +18 -6
  191. data/lib/woods/tasks.rb +3 -2
  192. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  193. data/lib/woods/unblocked/exporter.rb +59 -70
  194. data/lib/woods/version.rb +1 -1
  195. data/lib/woods/watch/boot_snapshot.rb +52 -0
  196. data/lib/woods/watch/daemon.rb +136 -28
  197. data/lib/woods/watch/listen_watcher.rb +4 -0
  198. data/lib/woods/watch/polling_watcher.rb +5 -1
  199. data/lib/woods/watch/status.rb +20 -15
  200. data/lib/woods/watch/tree_scan.rb +21 -13
  201. data/lib/woods/watch/watcher.rb +4 -1
  202. data/lib/woods.rb +50 -11
  203. data/plugin/.claude-plugin/plugin.json +1 -1
  204. data/plugin/hooks/adapters/normalize.jq +15 -0
  205. data/plugin/hooks/adapters/normalize.rb +63 -0
  206. data/plugin/hooks/hooks.json +20 -0
  207. data/plugin/hooks/woods-context.sh +50 -0
  208. data/plugin/hooks/woods-input-rules.sh +159 -0
  209. data/plugin/hooks/woods-opencode.mjs +65 -0
  210. data/plugin/hooks/woods-post-edit.sh +2 -225
  211. data/plugin/hooks/woods-refresh.sh +260 -0
  212. data/plugin/hooks/woods-session-start.sh +47 -55
  213. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  214. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  215. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  216. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  217. data/plugin/skills/woods-setup/SKILL.md +107 -6
  218. metadata +84 -5
data/lib/tasks/woods.rake CHANGED
@@ -20,359 +20,9 @@
20
20
  # case in CI and Docker — so a non-ASCII byte (e.g. a git branch name in the
21
21
  # manifest) raises Encoding::InvalidByteSequenceError. AtomicFile.write is
22
22
  # binmode, so the read side must go through AtomicFile.read to match.
23
- require 'woods/atomic_file'
24
- require 'woods/generation'
25
- require 'woods/git_command'
23
+ require 'woods/rake_helpers'
26
24
 
27
25
  namespace :woods do
28
- # ── Multi-instance helpers (#164 phase 4) ────────────────────────────────
29
- #
30
- # Worktrees are disjoint by construction (each has its own Rails.root and
31
- # its own output dir), so these only ever mediate writers against the *same*
32
- # index: a manual rake run, a hook-triggered sync, and the watch daemon.
33
-
34
- # Run a block holding the extraction lock, waiting for another writer to
35
- # finish.
36
- #
37
- # This used to proceed *without* the lock after 30s, on the reasoning that a
38
- # daemon cycle is milliseconds so a longer wait meant something unusual. That
39
- # reasoning was wrong in the case that matters: a cycle includes a
40
- # storm-triggered `extract_all`, which on a large host app runs for minutes.
41
- # Proceeding then means two writers load `dependency_graph.json`, mutate
42
- # divergent copies, and the last one silently discards the other's work — then
43
- # bumps the generation, marking the clobbered graph fresh. Per-file atomic
44
- # writes do not help, because the file *set* is not atomic.
45
- #
46
- # So the wait is now generous and the failure explicit. `WOODS_LOCK_WAIT`
47
- # overrides it; exceeding it exits non-zero rather than corrupting the index,
48
- # which is the outcome a CI job or a developer can actually act on.
49
- # Fail the task when a run reported per-item errors (INF-4 / EXP-4).
50
- #
51
- # A printed-but-green run is invisible in the CI/cron pipelines these tasks
52
- # target: a revoked API key, a full vector store, or a 401 on every page
53
- # prints `Errors: N` and the job stays green while the index or the export
54
- # target rots. `woods:unblocked_sync` and `woods:obsidian` already exit 1 on
55
- # the same shape, and #270 fixed it for the extraction family; the embedding
56
- # and Notion tasks carry identical CI exposure.
57
- #
58
- # Partial progress is deliberately *not* a carve-out here. The unblocked
59
- # exporter has one (budget exhaustion with progress is the expected
60
- # cold-start shape and converges on the next run); an embedding or Notion
61
- # error is a genuine failure per unit and does not self-heal, so any
62
- # non-zero count fails.
63
- #
64
- # @param errors [Integer, Array, nil] the run's error count or error list
65
- # @return [void]
66
- def woods_exit_on_reported_errors(errors)
67
- # The embed indexer reports a count; the exporters report a list.
68
- # (`Integer#size` exists and answers 8, so this must branch on the type.)
69
- count = errors.is_a?(Numeric) ? errors.to_i : Array(errors).size
70
- return if count.zero?
71
-
72
- puts
73
- puts 'Run completed with errors — failing so CI surfaces it.'
74
- exit 1
75
- end
76
-
77
- def woods_with_extraction_lock(output_dir, wait: nil, &block)
78
- # Requires first. The default wait reads a constant from the daemon, so
79
- # resolving it above these lines NameError'd every write task — the same
80
- # load-order bug as the missing require in `woods:watch`, reintroduced one
81
- # method over by the fix for it.
82
- require 'woods/coordination/pipeline_lock'
83
- require 'woods/coordination/lock_heartbeat'
84
- require 'woods/watch/daemon'
85
-
86
- wait ||= Float(ENV.fetch('WOODS_LOCK_WAIT', Woods::Watch::Daemon::LOCK_STALE_TIMEOUT))
87
-
88
- lock = Woods::Coordination::PipelineLock.new(
89
- lock_dir: output_dir.to_s,
90
- name: Woods::Watch::Daemon::LOCK_NAME,
91
- stale_timeout: Woods::Watch::Daemon::LOCK_STALE_TIMEOUT
92
- )
93
-
94
- woods_abort_on_lock_timeout(wait) unless woods_acquire_within(lock, wait)
95
-
96
- begin
97
- Woods::Coordination::LockHeartbeat.run(lock, &block)
98
- ensure
99
- lock.release
100
- end
101
- end
102
-
103
- # Poll for the lock until `wait` seconds have elapsed.
104
- #
105
- # Monotonic, so a clock adjustment mid-wait cannot cut the window short or
106
- # extend it indefinitely.
107
- #
108
- # @return [Boolean] whether the lock was acquired
109
- def woods_acquire_within(lock, wait)
110
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
111
- acquired = lock.acquire
112
- until acquired || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
113
- sleep 0.25
114
- acquired = lock.acquire
115
- end
116
- acquired
117
- end
118
-
119
- def woods_abort_on_lock_timeout(wait)
120
- warn "ERROR: another writer has held the extraction lock for #{wait.round}s."
121
- warn 'Refusing to write: two concurrent writers rewrite the dependency graph from divergent'
122
- warn 'copies, and the loser\'s work is discarded under a generation that says "fresh".'
123
- warn 'Set WOODS_LOCK_WAIT to wait longer, or stop the other writer.'
124
- exit 1
125
- end
126
-
127
- # Is a watch daemon already maintaining this index?
128
- #
129
- # A session-start or worktree hook that fires `woods:incremental` on a tree
130
- # a daemon is already watching is pure duplicated work — and it contends for
131
- # the lock the daemon needs. Set WOODS_IGNORE_WATCH=1 to run anyway.
132
- #
133
- # Liveness alone is not coverage, and the difference matters. A `:running`
134
- # daemon reconciles everything modified since the index's last successful
135
- # publish when it starts (Daemon#catch_up), so changes that predate it are
136
- # covered whether or not it witnessed them — that is what makes standing down
137
- # safe. A `:degraded` daemon is alive but *cannot* currently update, so
138
- # standing down for it would report success over work nothing is doing.
139
- #
140
- # @return [Symbol] `:none`, `:running`, or `:degraded`
141
- def woods_daemon_coverage(output_dir)
142
- return :none if ENV['WOODS_IGNORE_WATCH'] == '1'
143
-
144
- require 'woods/watch/status'
145
- status = Woods::Watch::Status.new(output_dir: output_dir)
146
- return :none unless status.alive?
147
-
148
- status.read['state'] == 'degraded' ? :degraded : :running
149
- rescue StandardError
150
- :none
151
- end
152
-
153
- # Delete the index without yanking it out from under another writer (#170).
154
- #
155
- # The old `woods:clean` body was a bare rm_rf. Run mid-daemon-cycle it
156
- # deleted the daemon's own extraction lock, so the "writers serialize on
157
- # PipelineLock" invariant evaporated at the exact moment a writer was
158
- # mid-graph-rewrite. Now: refuse while a daemon is alive (same stand-down
159
- # check as `woods:incremental`; WOODS_IGNORE_WATCH=1 overrides), then take
160
- # the extraction lock like every other writer, delete everything EXCEPT the
161
- # lock file itself, and let release remove the lock last.
162
- #
163
- # The directory the published generation's payload lives in.
164
- #
165
- # Read-side tasks resolve through this rather than the output root: an index
166
- # publishing per-generation payloads keeps only `generation.json`, `dumps/`,
167
- # `payloads/` and the lock files at the root. A flat index resolves to the
168
- # root unchanged.
169
- #
170
- # @param output_dir [Pathname, String] index directory
171
- # @return [Pathname]
172
- def woods_payload_dir(output_dir)
173
- Woods::Generation.new(output_dir: output_dir).payload_dir
174
- end
175
-
176
- # @param output_dir [Pathname, String] index directory
177
- # @param wait [Numeric, nil] seconds to wait for the lock (default: the
178
- # shared writer wait; injectable so a spec need not sit out the window)
179
- # @return [Symbol] `:cleaned`, or `:refused` when a live daemon is
180
- # maintaining this index
181
- def woods_clean_index(output_dir, wait: nil)
182
- require 'woods/watch/daemon'
183
-
184
- output_dir = Pathname.new(output_dir)
185
-
186
- unless woods_daemon_coverage(output_dir) == :none
187
- warn 'ERROR: a watch daemon is maintaining this index — refusing to delete it out from under it.'
188
- warn 'Stop the daemon first, or set WOODS_IGNORE_WATCH=1 to clean anyway.'
189
- return :refused
190
- end
191
-
192
- lock_name = Woods::Watch::Daemon::LOCK_NAME
193
- woods_with_extraction_lock(output_dir, wait: wait) do
194
- woods_sweep_index_dir(output_dir, lock_name)
195
- end
196
-
197
- # The lock was released (and its file removed) above; drop the guard so
198
- # the directory can empty, then remove the now-empty directory. Another
199
- # writer may legitimately have recreated content between release and here
200
- # — a non-empty directory is left alone rather than forced.
201
- woods_remove_if_empty(output_dir, lock_name)
202
- :cleaned
203
- end
204
-
205
- # Delete every index artifact except the lock file and its transaction
206
- # guard. The guard lives inside the lock directory and a contender may hold
207
- # its flock right now — deleting it mid-sweep would split the flock across
208
- # two inodes and defeat the mutual exclusion it provides.
209
- def woods_sweep_index_dir(output_dir, lock_name)
210
- preserved = [
211
- output_dir.join("#{lock_name}.lock"),
212
- output_dir.join(Woods::Coordination::PipelineLock.guard_filename(lock_name))
213
- ]
214
- output_dir.children.each do |entry|
215
- FileUtils.rm_rf(entry) unless preserved.include?(entry)
216
- end
217
- end
218
-
219
- def woods_remove_if_empty(output_dir, lock_name)
220
- FileUtils.rm_f(output_dir.join(Woods::Coordination::PipelineLock.guard_filename(lock_name)))
221
- Dir.rmdir(output_dir)
222
- rescue SystemCallError
223
- nil
224
- end
225
-
226
- # The root containing the Rakefile that loaded this task file.
227
- #
228
- # `woods:watch_status` intentionally avoids Rails boot, so it cannot ask
229
- # `Rails.root` for the conventional output path. `Dir.pwd` is not a stable
230
- # substitute: `rake -f /app/Rakefile` and worktree launchers may invoke the
231
- # task from somewhere else. Rake retains the selected Rakefile even when it
232
- # does not chdir, which gives the same application root without loading the
233
- # environment.
234
- #
235
- # @return [String] absolute directory containing the active Rakefile
236
- def woods_task_root
237
- rakefile = Rake.application.rakefile
238
- return Rake.application.original_dir if rakefile.nil? || rakefile.empty?
239
-
240
- # Rake may keep this relative (`Rakefile`) after searching upward from a
241
- # nested invocation. At task execution its cwd is the directory it loaded
242
- # that relative file from. An explicit absolute `-f` is already complete.
243
- File.dirname(File.expand_path(rakefile))
244
- end
245
-
246
- # Changed paths across a git range, for `woods:incremental`'s CI branches.
247
- #
248
- # `git diff --name-only` split on lines corrupted three things at once: a
249
- # path containing a newline split into two entries; a non-ASCII path came
250
- # back octal-escaped inside quotes under git's default `core.quotePath`,
251
- # which the dispatcher then can't match to any rule; and a rename reported
252
- # only the new path, so the old path's unit was never pruned. `-z` +
253
- # `--name-status` + `--no-renames` fixes all three: NUL-delimited records,
254
- # `core.quotePath=false` unescaped, and a rename decomposed by git itself
255
- # into a separate `A <new>` and `D <old>` record rather than one `R` record
256
- # naming both.
257
- #
258
- # The diff is rooted at the extracted application (`git -C Rails.root`),
259
- # consistent with {Woods::GitProvenance} (#262): extraction launched from
260
- # another checkout must not diff that checkout's history.
261
- #
262
- # The child status is carried out, not discarded (M1): `Open3.capture2`
263
- # turned an unresolvable range — a GitLab zero-SHA, an unfetched GitHub base
264
- # ref, garbage — into an empty change set the caller could not tell apart
265
- # from "nothing changed", and `woods:incremental` exited 0 over a sync that
266
- # never ran.
267
- #
268
- # @param range [String] a git diff range/revision expression
269
- # @param root [Pathname, String] repository root the diff runs against
270
- # @return [Array(Array<String>, String, nil)] the changed paths (both halves
271
- # of any rename included), or nil paths plus a human-readable failure when
272
- # the range could not be resolved
273
- def woods_changed_paths_for_range(range, root: Rails.root)
274
- require 'open3'
275
- output, error, status = Open3.capture3(
276
- *Woods::GitCommand.argv(
277
- root, '-c', 'core.quotePath=false',
278
- 'diff', '--name-status', '-z', '--no-renames', range
279
- )
280
- )
281
- return [woods_parse_git_diff_name_status(output), nil] if status.success?
282
-
283
- [nil, "#{error.strip} (git exited #{status.exitstatus})"]
284
- rescue SystemCallError => e
285
- # No git binary at all (a slim container image, git removed after
286
- # checkout). Errno::ENOENT out of `Open3.capture3` used to kill the task
287
- # with a backtrace: non-zero, so safe, but it bypassed the decision below
288
- # — the `:running`-daemon stand-down branch was unreachable, and the
289
- # operator got a stack trace instead of the remediation text. An absent
290
- # binary is an unresolvable range like any other (INF-12).
291
- [nil, "git unavailable: #{e.message}"]
292
- end
293
-
294
- # The changed-path set `woods:incremental` will process, or a stand-down.
295
- #
296
- # An explicit `CHANGED_FILES` list bypasses git entirely. Otherwise the
297
- # range comes from the CI environment (GitLab's before-SHA, GitHub's base
298
- # ref) or defaults to the last commit.
299
- #
300
- # A failed range is a decision, not a skip (M1): the changed-file set is
301
- # unknown, so silently extracting nothing would exit 0 over work that never
302
- # happened and leave CI drift unbounded. When a `:running` daemon maintains
303
- # the index, its start-up catch-up covers whatever changed, so standing down
304
- # with a printed reason is safe; a degraded daemon covers nothing, so the
305
- # run fails like any other uncovered case. The decision runs BEFORE the
306
- # task's empty-range exit — a failed range must never be mistaken for an
307
- # empty one.
308
- #
309
- # @param output_dir [Pathname, String] index directory, for daemon coverage
310
- # @return [Array<String>] changed paths
311
- def woods_incremental_changed_paths(output_dir)
312
- explicit = ENV.fetch('CHANGED_FILES', nil)
313
- return explicit.split(',').map(&:strip) if explicit
314
-
315
- range = woods_incremental_range
316
- changed_files, failure = woods_changed_paths_for_range(range)
317
- return changed_files unless failure
318
-
319
- if woods_daemon_coverage(output_dir) == :running
320
- puts "Could not resolve the git diff range #{range.inspect} (#{failure})."
321
- puts 'A watch daemon is maintaining this index, so its catch-up covers the changed paths — standing down.'
322
- puts 'To extract now anyway, repair or provide the range (check the CI env refs),'
323
- puts 'set CHANGED_FILES explicitly, or run a full woods:extract.'
324
- exit 0
325
- end
326
-
327
- warn "ERROR: could not resolve the git diff range #{range.inspect}: #{failure}"
328
- warn 'The changed-file set is unknown, so incremental extraction would silently index nothing.'
329
- warn 'Fix the range (a zero SHA or an unfetched base ref resolve to nothing), or run a full woods:extract.'
330
- exit 1
331
- end
332
-
333
- # The git range `woods:incremental` diffs, from the CI environment or the
334
- # last-commit default.
335
- #
336
- # @return [String]
337
- def woods_incremental_range
338
- if ENV['CI_COMMIT_BEFORE_SHA']
339
- # GitLab CI
340
- "#{ENV['CI_COMMIT_BEFORE_SHA']}..#{ENV.fetch('CI_COMMIT_SHA', nil)}"
341
- elsif ENV['GITHUB_BASE_REF']
342
- # GitHub Actions PR
343
- "origin/#{ENV['GITHUB_BASE_REF']}...HEAD"
344
- else
345
- # Default: changes since last commit
346
- 'HEAD~1'
347
- end
348
- end
349
-
350
- # Parse NUL-delimited `git diff --name-status -z --no-renames` output.
351
- #
352
- # Each record is `<status>\0<path>\0` — `--no-renames` guarantees a single
353
- # path per record, since it is what stops git emitting a two-path `R`/`C`
354
- # record in the first place. Status letters are not inspected beyond "did
355
- # git report anything at all"; a deleted path still needs to reach the
356
- # change set so its unit can be pruned.
357
- #
358
- # A bare `Open3.capture2` read is tagged with the process's default
359
- # external encoding — US-ASCII under `LANG=C`, this daemon's usual
360
- # environment (see `AtomicFile.read`'s gotcha) — so a UTF-8 path is a
361
- # US-ASCII string containing invalid bytes until re-tagged.
362
- #
363
- # @param output [String] raw NUL-delimited git output
364
- # @return [Array<String>] changed paths
365
- def woods_parse_git_diff_name_status(output)
366
- fields = output.dup.force_encoding(Encoding::UTF_8).split("\x00")
367
- paths = []
368
- fields.each_slice(2) do |status, path|
369
- break if path.nil?
370
-
371
- paths << path unless status.nil? || status.empty?
372
- end
373
- paths
374
- end
375
-
376
26
  desc 'Full extraction of codebase for indexing'
377
27
  task extract: :environment do
378
28
  require 'woods/extractor'
@@ -384,7 +34,7 @@ namespace :woods do
384
34
  puts
385
35
 
386
36
  extractor = Woods::Extractor.new(output_dir: output_dir)
387
- results = woods_with_extraction_lock(output_dir) do
37
+ results = Woods::RakeHelpers.woods_with_extraction_lock(output_dir) do
388
38
  extracted = extractor.extract_all
389
39
  extractor.raise_on_publication_failure!
390
40
  extracted
@@ -415,7 +65,7 @@ namespace :woods do
415
65
  # decided here too — stand down under a covering daemon, or fail loudly —
416
66
  # BEFORE the empty-range exit below can mistake it for "nothing changed"
417
67
  # (M1).
418
- changed_files = woods_incremental_changed_paths(output_dir)
68
+ changed_files = Woods::RakeHelpers.woods_incremental_changed_paths(output_dir)
419
69
 
420
70
  # Filter to paths that imply extraction work. The rule set lives in
421
71
  # PathDispatcher alongside the dispatch itself — a second hand-maintained
@@ -429,7 +79,7 @@ namespace :woods do
429
79
  exit 0
430
80
  end
431
81
 
432
- case woods_daemon_coverage(output_dir)
82
+ case Woods::RakeHelpers.woods_daemon_coverage(output_dir)
433
83
  when :running
434
84
  puts 'A watch daemon is maintaining this index — skipping.'
435
85
  puts 'It reconciles anything changed since the last publish when it starts, so these are covered.'
@@ -446,7 +96,7 @@ namespace :woods do
446
96
  puts
447
97
 
448
98
  extractor = Woods::Extractor.new(output_dir: output_dir)
449
- affected = woods_with_extraction_lock(output_dir) do
99
+ affected = Woods::RakeHelpers.woods_with_extraction_lock(output_dir) do
450
100
  extracted = extractor.extract_changed(changed_files)
451
101
  extractor.raise_on_publication_failure!
452
102
  extracted
@@ -456,11 +106,30 @@ namespace :woods do
456
106
  puts "Re-extracted #{affected.size} affected units."
457
107
  end
458
108
 
109
+ desc 'Consume an encoded hook refresh batch (internal plugin transport)'
110
+ task :hook_refresh, [:batch] do |_task, args|
111
+ require 'woods/hooks/refresh'
112
+ Woods::Hooks::Refresh.new(args[:batch]).call
113
+ end
114
+
115
+ desc 'Verify indexed source content without booting Rails (encoded options are optional)'
116
+ task :source_status, [:options] do |_task, args|
117
+ require 'woods/source_inputs/status'
118
+ puts JSON.generate(Woods::SourceInputs::Status.from_transport(args[:options]))
119
+ end
120
+
459
121
  desc 'Tend the garden — incremental extraction (alias for incremental)'
460
122
  task tend: :incremental
461
123
 
462
124
  desc 'Watch the app and keep the index current (resident daemon)'
463
- task watch: :environment do
125
+ task :watch do
126
+ # Observe inputs before Rails initializes. A snapshot taken in Daemon.new
127
+ # would silently bless edits made while initializers were running.
128
+ require 'woods/watch/boot_snapshot'
129
+ environment = Rake::Task[:environment]
130
+ fresh_environment = !environment.already_invoked && !Rails.application.initialized?
131
+ boot_snapshot = Woods::Watch::BootSnapshot.new(root: Rails.root) if fresh_environment
132
+ environment.invoke
464
133
  # Both, and the extractor is not optional. The daemon's default
465
134
  # extractor_factory names Woods::Extractor lazily, so omitting this require
466
135
  # loaded and started cleanly and then NameError'd on the first real cycle —
@@ -472,6 +141,12 @@ namespace :woods do
472
141
 
473
142
  output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
474
143
 
144
+ poll_interval = begin
145
+ Float(ENV.fetch('WOODS_WATCH_POLL_INTERVAL', Woods::Watch::Watcher::DEFAULT_POLL_INTERVAL))
146
+ rescue ArgumentError, TypeError
147
+ raise ArgumentError, 'WOODS_WATCH_POLL_INTERVAL must be a positive finite number of seconds'
148
+ end
149
+
475
150
  daemon = Woods::Watch::Daemon.new(
476
151
  output_dir: output_dir,
477
152
  root: Rails.root,
@@ -479,12 +154,14 @@ namespace :woods do
479
154
  full_extraction_threshold: Integer(
480
155
  ENV.fetch('WOODS_WATCH_FULL_THRESHOLD', Woods::Watch::Daemon::DEFAULT_FULL_EXTRACTION_THRESHOLD)
481
156
  ),
157
+ poll_interval: poll_interval,
482
158
  # The documented fix for a container watching a bind mount, where native
483
159
  # FS events do not propagate and the daemon would sit silent. Nothing
484
160
  # exposed it before, which made the advice unfollowable.
485
161
  force_polling: ENV['WOODS_WATCH_POLL'] == '1', # container autodetect also applies; see Watcher.containerized?
486
162
  idle_timeout: ENV.fetch('WOODS_WATCH_IDLE_TIMEOUT', nil) && Float(ENV.fetch('WOODS_WATCH_IDLE_TIMEOUT')),
487
163
  catch_up: ENV['WOODS_WATCH_CATCH_UP'] != '0',
164
+ boot_snapshot: boot_snapshot,
488
165
  logger: Rails.logger
489
166
  )
490
167
 
@@ -530,7 +207,7 @@ namespace :woods do
530
207
  require 'woods/watch/status'
531
208
  require 'json'
532
209
 
533
- output_dir = ENV.fetch('WOODS_OUTPUT') { File.join(woods_task_root, 'tmp/woods') }
210
+ output_dir = ENV.fetch('WOODS_OUTPUT') { File.join(Woods::RakeHelpers.woods_task_root, 'tmp/woods') }
534
211
  status = Woods::Watch::Status.new(output_dir: output_dir)
535
212
 
536
213
  puts JSON.pretty_generate(status.read)
@@ -567,7 +244,7 @@ namespace :woods do
567
244
  # clobbered state is fresh. Atomic writes do not help: each write is
568
245
  # individually intact, the *set* is not. So it serializes like the others.
569
246
  begin
570
- result = woods_with_extraction_lock(output_dir) do
247
+ result = Woods::RakeHelpers.woods_with_extraction_lock(output_dir) do
571
248
  refreshed = extractor.refresh(*keys)
572
249
  extractor.raise_on_publication_failure!
573
250
  refreshed
@@ -603,7 +280,7 @@ namespace :woods do
603
280
  # regardless of `include_framework_sources` — an explicit invocation is
604
281
  # the escape hatch the knob deliberately leaves open.
605
282
  extractor = Woods::Extractor.new(output_dir: output_dir)
606
- result = woods_with_extraction_lock(output_dir) do
283
+ result = Woods::RakeHelpers.woods_with_extraction_lock(output_dir) do
607
284
  refreshed = extractor.refresh(:rails_source)
608
285
  extractor.raise_on_publication_failure!
609
286
  refreshed
@@ -624,7 +301,7 @@ namespace :woods do
624
301
  exit 1
625
302
  end
626
303
 
627
- manifest_path = woods_payload_dir(output_dir).join('manifest.json')
304
+ manifest_path = Woods::RakeHelpers.woods_payload_dir(output_dir).join('manifest.json')
628
305
  unless manifest_path.exist?
629
306
  puts 'ERROR: Manifest not found. Run extraction first.'
630
307
  exit 1
@@ -675,7 +352,7 @@ namespace :woods do
675
352
  exit 1
676
353
  end
677
354
 
678
- payload_dir = woods_payload_dir(output_dir)
355
+ payload_dir = Woods::RakeHelpers.woods_payload_dir(output_dir)
679
356
  manifest_path = payload_dir.join('manifest.json')
680
357
  manifest = manifest_path.exist? ? JSON.parse(Woods::AtomicFile.read(manifest_path)) : {}
681
358
 
@@ -740,7 +417,7 @@ namespace :woods do
740
417
 
741
418
  if output_dir.exist?
742
419
  puts "Removing #{output_dir}..."
743
- exit 1 unless woods_clean_index(output_dir) == :cleaned
420
+ exit 1 unless Woods::RakeHelpers.woods_clean_index(output_dir) == :cleaned
744
421
  puts 'Done.'
745
422
  else
746
423
  puts 'Index directory does not exist.'
@@ -750,38 +427,11 @@ namespace :woods do
750
427
  desc 'Clear the brush — remove index (alias for clean)'
751
428
  task clear: :clean
752
429
 
753
- # Resolve the configured retrieval stack and run one ad-hoc query (#178).
754
- #
755
- # Every backend — embedding provider, vector store, metadata store, graph
756
- # store — is resolved through Woods::Builder from Woods.configuration,
757
- # the same wiring `woods:embed` writes through
758
- # (Woods::Tasks.build_embed_indexer). The old task body hardcoded
759
- # Ollama + InMemory + SQLite + Memory, so on any other configured stack it
760
- # queried backends the embed run never wrote to and silently returned
761
- # nothing.
762
- #
763
- # In-memory stores start empty in a fresh process; hosts on the :local /
764
- # :shared_filesystem presets should query through woods-mcp, which
765
- # hydrates them from the dumps on disk.
766
- #
767
- # @param query [String] natural-language retrieval query
768
- # @return [String] human-formatted retrieval output
769
- def woods_run_retrieval(query)
770
- require 'woods'
771
- require 'woods/formatting/human_adapter'
772
-
773
- config = Woods.configuration
774
- retriever = Woods::Builder.new(config).build_retriever
775
- result = retriever.retrieve(query, budget: config.max_context_tokens)
776
-
777
- Woods::Formatting::HumanAdapter.new.format(result)
778
- end
779
-
780
430
  # Internal debugging tool — hidden from `rails -T`
781
431
  task :retrieve, [:query] => :environment do |_t, args|
782
432
  query = args[:query] || raise('Usage: rake woods:retrieve[query]')
783
433
 
784
- puts woods_run_retrieval(query)
434
+ puts Woods::RakeHelpers.woods_run_retrieval(query)
785
435
  end
786
436
 
787
437
  desc 'Embed all extracted units'
@@ -800,9 +450,9 @@ namespace :woods do
800
450
 
801
451
  indexer = Woods::Tasks.build_embed_indexer
802
452
  puts 'Embedding all extracted units...'
803
- stats = woods_with_extraction_lock(output_dir) { indexer.index_all }
453
+ stats = Woods::RakeHelpers.woods_with_extraction_lock(output_dir) { indexer.index_all }
804
454
  Woods::Tasks.print_embed_stats(stats, mode: :full)
805
- woods_exit_on_reported_errors(stats[:errors])
455
+ Woods::RakeHelpers.woods_exit_on_reported_errors(stats[:errors])
806
456
  end
807
457
 
808
458
  desc 'Nest the data — embed all units (alias for embed)'
@@ -818,9 +468,9 @@ namespace :woods do
818
468
 
819
469
  indexer = Woods::Tasks.build_embed_indexer
820
470
  puts 'Embedding changed units (incremental)...'
821
- stats = woods_with_extraction_lock(output_dir) { indexer.index_incremental }
471
+ stats = Woods::RakeHelpers.woods_with_extraction_lock(output_dir) { indexer.index_incremental }
822
472
  Woods::Tasks.print_embed_stats(stats, mode: :incremental)
823
- woods_exit_on_reported_errors(stats[:errors])
473
+ Woods::RakeHelpers.woods_exit_on_reported_errors(stats[:errors])
824
474
  end
825
475
 
826
476
  desc 'Hone the blade — incremental embedding (alias for embed_incremental)'
@@ -939,7 +589,7 @@ namespace :woods do
939
589
  end
940
590
 
941
591
  output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
942
- graph_path = File.join(woods_payload_dir(output_dir).to_s, 'dependency_graph.json')
592
+ graph_path = File.join(Woods::RakeHelpers.woods_payload_dir(output_dir).to_s, 'dependency_graph.json')
943
593
 
944
594
  unless File.exist?(graph_path)
945
595
  puts "ERROR: Dependency graph not found at #{graph_path}"
@@ -951,7 +601,7 @@ namespace :woods do
951
601
  graph = Woods::DependencyGraph.from_h(graph_data)
952
602
 
953
603
  max_depth = ENV.fetch('MAX_DEPTH', 5).to_i
954
- assembler = Woods::FlowAssembler.new(graph: graph, extracted_dir: woods_payload_dir(output_dir).to_s)
604
+ assembler = Woods::FlowAssembler.new(graph: graph, extracted_dir: Woods::RakeHelpers.woods_payload_dir(output_dir).to_s)
955
605
  flow = assembler.assemble(entry_point, max_depth: max_depth)
956
606
 
957
607
  format = ENV.fetch('FORMAT', 'markdown').downcase
@@ -1020,7 +670,7 @@ namespace :woods do
1020
670
  puts " Errors: #{stats[:errors].size}"
1021
671
  stats[:errors].first(5).each { |e| puts " - #{e}" }
1022
672
  puts " ... and #{stats[:errors].size - 5} more" if stats[:errors].size > 5
1023
- woods_exit_on_reported_errors(stats[:errors])
673
+ Woods::RakeHelpers.woods_exit_on_reported_errors(stats[:errors])
1024
674
  end
1025
675
  end
1026
676