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
@@ -0,0 +1,667 @@
1
+ # The Watch Daemon (`woods:watch`)
2
+
3
+ A resident, booted-app process that keeps the index current as files change,
4
+ instead of as-fresh-as-the-last-explicit-rake-run.
5
+
6
+ Background: [#164](https://github.com/lost-in-the/woods/issues/164), phase 2.
7
+ The correctness work it stands on is in
8
+ [INCREMENTAL_EXTRACTION.md](INCREMENTAL_EXTRACTION.md), a watcher on top of an
9
+ incorrect incremental path just delivers wrong answers with lower latency.
10
+
11
+ > **Development only.** The daemon adds no network listener and no new
12
+ > transport. It watches the filesystem and writes to `tmp/woods`. Don't run it
13
+ > in production; there is nothing to gain and a booted process to lose.
14
+
15
+ ## Running it
16
+
17
+ ```bash
18
+ bundle exec rake woods:watch # alias: woods:guard
19
+ ```
20
+
21
+ ```
22
+ Watching /app, index at /app/tmp/woods
23
+ Ctrl-C to stop.
24
+ ```
25
+
26
+ | Environment variable | Default | Meaning |
27
+ |---|---|---|
28
+ | `WOODS_OUTPUT` | `tmp/woods` | Index directory |
29
+ | `WOODS_WATCH_DEBOUNCE` | `0.4` | Seconds of quiet before a batch is considered settled |
30
+ | `WOODS_WATCH_FULL_THRESHOLD` | `50` | Actionable changed-file count above which a full extraction replaces incremental |
31
+ | `WOODS_WATCH_POLL` | unset | `1` forces the polling backend, set this inside a container watching a bind mount |
32
+ | `WOODS_WATCH_IDLE_TIMEOUT` | unset | Seconds of quiet after which a dormant daemon exits |
33
+ | `WOODS_WATCH_CATCH_UP` | `1` | `0` skips the startup reconciliation |
34
+
35
+ Run it under a supervisor. When boot-captured configuration changes the daemon
36
+ exits `75` (`EX_TEMPFAIL`) on purpose, see [Restart triggers](#restart-triggers).
37
+
38
+ ```yaml
39
+ # Procfile.dev
40
+ web: bin/rails server
41
+ woods: bundle exec rake woods:watch
42
+ ```
43
+
44
+ ## One cycle
45
+
46
+ ```
47
+ watch → debounce → classify → reload if needed → extract → publish
48
+ ```
49
+
50
+ **Classify** is the step that matters. Extraction reads the *runtime*, `ActiveRecord::Base.descendants`, `Rails.application.routes`, resolved config,
51
+ callback chains on loaded classes, so "a file changed" and "re-reading it is
52
+ now worth anything" are different questions. `Woods::ReloadPolicy` answers the
53
+ second one; the table of path classes lives in
54
+ [INCREMENTAL_EXTRACTION.md](INCREMENTAL_EXTRACTION.md#what-a-change-actually-requires-reload-restart-or-neither).
55
+
56
+ **Publish** bumps `generation.json`, and only ever after a successful write.
57
+ A reader that sees generation N knows the files for N are already on disk, and
58
+ a run that failed leaves the number alone, so staleness stays honest.
59
+
60
+ ## Restart triggers
61
+
62
+ Rails' reloader replaces autoloaded constants and nothing else. It does not
63
+ re-run initializers, re-resolve `Rails.application.config`, or rebuild the
64
+ schema cache, all of which Woods captures. Changes to dependency/Ruby selection
65
+ files (`Gemfile`, `Gemfile.lock`, `.ruby-version`), `.env*`, Rails
66
+ application/boot/environment files, initializers, environments, credentials,
67
+ database/schema files, `config/settings.yml`, `config/settings/*.yml`, or
68
+ boot-captured service config
69
+ (`config/{cable,storage,sidekiq,puma,cache,queue}.yml`, including `.yaml`)
70
+ make the daemon write a degraded status, stop, and exit `75` for a supervisor
71
+ to restart it. Scheduled-job YAML remains an in-process re-extraction input.
72
+ The exact matchers live in `lib/woods/reload_policy.rb`.
73
+
74
+ This is `rails/spring`'s contract, copied deliberately: Spring's staleness bugs
75
+ came from under-scoping exactly this set, so the boundary here is drawn on the
76
+ generous side.
77
+
78
+ The same escalation happens when the app *can't* reload at all, a boot with
79
+ `config.enable_reloading = false`. Extracting against constants that no longer
80
+ match their source would be worse than saying so.
81
+
82
+ ## Failure posture
83
+
84
+ A syntax error mid-edit is normal; it happens every time someone saves halfway
85
+ through a thought. The daemon therefore never crash-loops and never publishes a
86
+ partial write:
87
+
88
+ | Failure | What happens |
89
+ |---|---|
90
+ | Reload raises (`SyntaxError`, `NameError`) | Degraded status naming the reason; index intact at generation N; retried on the next event |
91
+ | Extraction raises | Degraded status; generation not advanced |
92
+ | Payload directory can't be opened, over a payload-born index | Degraded status; generation not advanced. An incremental run only writes the units it touched, so there is no complete flat index it could fall back to publishing, see [Payload publishing](#payload-publishing) |
93
+ | Index written but the generation bump failed | Degraded status; paths carried forward. The extractor deliberately does not fail an otherwise-good extraction over an unwritable marker, but the marker *is* what readers refresh on, so the daemon cross-checks that the number moved rather than reporting `running` over an index nothing can see |
94
+ | Boot-captured config changed | Degraded status; daemon exits `75` |
95
+ | Watcher dies | Degraded status; daemon exits |
96
+
97
+ `tmp/woods/watch_status.json` carries the state:
98
+
99
+ ```json
100
+ { "state": "degraded",
101
+ "reason": "SyntaxError: unexpected end-of-input",
102
+ "generation": 41,
103
+ "pid": 4821,
104
+ "updated_at": "2026-07-27T04:55:12Z" }
105
+ ```
106
+
107
+ Three states, and the middle one is the point: `running` (current, or current
108
+ within a debounce window), `degraded` (alive but *cannot* update, index frozen
109
+ at a known generation, reason attached), `stopped` (nothing is maintaining this
110
+ index). A stale answer is only dangerous when nothing says so.
111
+
112
+ The file is written world-readable (0644) by design: host-side hooks read it
113
+ through a bind mount. Every other artifact Woods writes stays at 0600.
114
+
115
+ Note that `SyntaxError` is a `ScriptError`, not a `StandardError`. Rescuing
116
+ only the latter would let a half-typed file kill the daemon.
117
+
118
+ A cycle that fails to land its work never loses its paths. Lock contention, a
119
+ failed reload, and a raising extraction all carry the batch into `@pending`, and
120
+ the next cycle folds it back in, the files really did change, and no later
121
+ event will mention them again. The retry is not a tight loop: a degraded cycle
122
+ ends the drain and waits for the next event, because the cause needs an edit to
123
+ clear.
124
+
125
+ ### The heartbeat
126
+
127
+ `alive?` disbelieves a record older than `STALE_AFTER` (15 minutes), and cycle
128
+ boundaries are otherwise the only thing that writes one. So the daemon re-stamps
129
+ its record every `HEARTBEAT_INTERVAL` (a third of the window). Without it a
130
+ perfectly healthy daemon reads as dead after a quiet quarter-hour, the most
131
+ common state for a worktree nobody is typing in, and every caller that stands
132
+ down for a live daemon starts contending with it instead.
133
+
134
+ The heartbeat republishes the **last** state, not `running`. A degraded daemon
135
+ is still degraded between events, and saying otherwise is the one thing this
136
+ file exists to prevent.
137
+
138
+ The same tick is also when carried-forward paths get retried, but the retry
139
+ drain runs on **its own thread**, not the heartbeat's. Running it inline meant a
140
+ retried storm (`extract_all` on a large host) stopped the re-stamping and the
141
+ `PipelineLock` touch for its whole duration: past `LOCK_STALE_TIMEOUT` (600 s)
142
+ any waiting writer retires the live lock and two writers clobber one index, and
143
+ past `STALE_AFTER` (900 s) `woods:incremental` stops standing down at the same
144
+ moment. `drain`'s own `try_lock` still refuses overlapping drains, so at most
145
+ one retry is ever in flight.
146
+
147
+ ## Startup is not a clean slate
148
+
149
+ A daemon that only reacts to events it personally witnessed is stale the moment
150
+ it starts: edits and pulled commits that landed while nothing was watching are
151
+ invisible to it forever. That matters because callers stand down when a daemon
152
+ is alive, so *alive has to mean covered*.
153
+
154
+ So `run` reconciles before it waits. The watermark is `generation.json`'s mtime, written last on every successful run, so it means "when this index was last
155
+ known good", and everything modified since is uncovered, whoever changed it.
156
+ With no generation file there is no index, every file is uncovered, and the
157
+ storm threshold correctly turns that into one full extraction. A marker whose
158
+ payload pointer no longer resolves counts as no index too: the marker can
159
+ outlive the directory it names (a partial restore from a CI artifact, an
160
+ external cleanup targeting the large directories), and readers deliberately
161
+ degrade a dangling pointer to the index root, so trusting the mtime there would
162
+ report "current at startup" over a directory holding nothing.
163
+
164
+ **The watcher thread starts before this reconciliation runs, not after.** A
165
+ file saved while catch-up's own extraction is still in flight (which can take
166
+ minutes on a storm-triggered full run) used to be lost twice: no watcher
167
+ existed yet to see it, and the polling watcher takes its baseline snapshot
168
+ inside `start`, after the save, so its first diff already excluded it. Worse,
169
+ the save's mtime predates the generation bump catch-up publishes at the end, so
170
+ a future restart's watermark check would read the file as already covered,
171
+ permanently. Starting the watcher first closes that window; `enqueue`/`drain`
172
+ already tolerate the duplicate paths this produces against whatever catch-up
173
+ finds on its own via the tree scan.
174
+
175
+ Deletions need one extra step, because a deleted file leaves no mtime to scan:
176
+ if any path the index attributes a unit to is gone from disk, the daemon runs
177
+ one cycle with an *empty* change set, which reaches the ghost units through the
178
+ extractor's bounded deletion sweep. Deliberately empty, naming the paths would
179
+ make the deletions authoritative for every unit type, and some registered paths
180
+ are nominal (on Rails < 7.1, `ActiveRecord::SchemaMigration` registers a
181
+ convention path no app has), which authoritative deletion would wrongly remove.
182
+ The sweep carries the bounds that make reconciliation safe; the daemon only
183
+ supplies the trigger.
184
+
185
+ This is what makes the documented hook pattern safe:
186
+
187
+ ```bash
188
+ bundle exec rake woods:watch_status || start_the_daemon
189
+ bundle exec rake woods:incremental # stands down, the daemon has these
190
+ ```
191
+
192
+ Without the catch-up, the sync exits 0 while the changes that prompted it never
193
+ reach the index. `woods:incremental` still runs when the daemon is *degraded*:
194
+ alive but not updating is not coverage.
195
+
196
+ ## Storms
197
+
198
+ A branch switch or rebase touches hundreds of files at once. Above
199
+ `WOODS_WATCH_FULL_THRESHOLD`, N incremental steps cost more than one full
200
+ extraction and risk interleaving with a still-settling tree, so the daemon
201
+ falls back to a full run and logs that it did.
202
+
203
+ ## Watcher backends
204
+
205
+ | Backend | When | Trade-off |
206
+ |---|---|---|
207
+ | `listen` gem | Used automatically when the host has it | Native FS events; low latency, no idle CPU |
208
+ | Polling | Fallback; no dependency | Costs a scan per interval, but works across container bind mounts |
209
+
210
+ The fallback is not a consolation prize. Native FS events **do not propagate
211
+ reliably across container bind mounts**, `listen` documents this, and macOS
212
+ Docker VMs are the usual casualty. Since extraction typically runs inside a dev
213
+ container with the source bind-mounted, a host in that position should force
214
+ polling rather than trust a watcher that may sit silent while files change
215
+ under it:
216
+
217
+ ```bash
218
+ WOODS_WATCH_POLL=1 bundle exec rake woods:watch
219
+ ```
220
+
221
+ Selection is also self-correcting at runtime. If `listen` cannot start at all, inotify watch exhaustion (`ENOSPC`) is the usual reason on a large tree, the
222
+ daemon logs it and falls back to polling rather than exiting, because a daemon
223
+ costing some CPU beats one that never fires. Failures *after* startup are not
224
+ treated as backend failures: the rescue covers only the setup, so an error
225
+ raised by the extraction inside a callback surfaces as itself.
226
+
227
+ Polling compares `[mtime, size]` at full float resolution. Truncating mtime to
228
+ whole seconds loses a second write inside the same second permanently, there is
229
+ no later event to catch it, and save-then-formatter at a 1s interval is
230
+ entirely ordinary. Size is the tiebreaker for filesystems that really do offer
231
+ only whole seconds.
232
+
233
+ Ignored by default: `.git`, `node_modules`, `tmp`, `log`, `coverage`,
234
+ `vendor/bundle`, `public/assets`, `public/packs`, `storage`. That ignore list is
235
+ what keeps a polling scan bounded.
236
+
237
+ ## Placement
238
+
239
+ The spike asked for three placements to be compared and one chosen. Every
240
+ collaborator on `Woods::Watch::Daemon` is injected, so all three are reachable
241
+ from the same class, but the default is **(b), a dedicated daemon per
242
+ worktree**:
243
+
244
+ | Option | Verdict |
245
+ |---|---|
246
+ | **(b) Dedicated daemon**: *recommended default* | One extra booted app per worktree. Isolated: a crash, a restart, or a storm affects only the index. Lifecycle is drivable from worktree hooks. |
247
+ | **(a) Embedded in the dev server** via the Railtie | Marginal memory cost ~0 where a booted app already exists, but couples index freshness to the dev server running and puts extraction on its threads. `Daemon#process` is public precisely so a host can do this deliberately. |
248
+ | **(c) Host watcher + in-container session** | Solves bind-mount event unreliability, but with the most moving parts. Forcing the polling backend solves the same problem with none. |
249
+
250
+ Measured on the fixture app (Ruby 3.3, Rails 8.0):
251
+
252
+ | Measurement | Value |
253
+ |---|---|
254
+ | Ruby baseline RSS | 27.8 MB |
255
+ | + booted Rails app | 64.9 MB (+37.1) |
256
+ | + Woods daemon on top | 72.1 MB (+7.2) |
257
+ | Single-file cycle | 48–81 ms (p95 81 ms) |
258
+ | 8-file storm → full extraction | 227 ms |
259
+
260
+ The daemon's own footprint is small; the cost of option (b) is the booted app,
261
+ not Woods. That is why (a) is worth keeping available for hosts that already
262
+ pay for one.
263
+
264
+ #### Where a full extraction of the fixture app spends its time
265
+
266
+ One cold `extract_all` over `spec/dummy`, 147 units of which 119 are framework
267
+ sources, 7 repetitions, Ruby 4.0.6 / Rails 8.0.5.1, phase timers around the
268
+ orchestrator's own methods. Total **190 ms** at p50.
269
+
270
+ Every share below is that phase's milliseconds over the 190 ms total, so the
271
+ top-level rows add up to the total. Indented rows break their parent down and
272
+ are already counted in it.
273
+
274
+ | Phase | ms | share of 190 ms |
275
+ |---|---|---|
276
+ | extraction | 115 | 60.5% |
277
+ | &nbsp;&nbsp;of which `RailsSourceExtractor` | 100 | 52.6% |
278
+ | &nbsp;&nbsp;of which `ModelExtractor` | 4.4 | 2.3% |
279
+ | &nbsp;&nbsp;of which every other extractor | 10 | 5.3% |
280
+ | `write_results` | 23 | 12.1% |
281
+ | git enrichment | 22 | 11.6% |
282
+ | graph analysis (`GraphAnalyzer#analyze`) | 6.1 | 3.2% |
283
+ | &nbsp;&nbsp;of which PageRank | 3.9 | 2.1% |
284
+ | manifest, graph and analysis writes | 6.9 | 3.6% |
285
+ | orphan sweep | 2.6 | 1.4% |
286
+ | dedupe, package annotation, dependents, path normalisation, publish | 0.6 | 0.3% |
287
+ | not attributed to a timed phase | 13.8 | 7.3% |
288
+ | **total** | **190** | **100%** |
289
+
290
+ The unattributed row is the orchestration between the timed phases: output
291
+ directory setup, the `ModelNameCache` reset, rebuilding the graph from the
292
+ deduped results, and the payload bookkeeping. It is named rather than dropped so
293
+ the column is a real accounting.
294
+
295
+ **The graph layers are not where the time goes.** PageRank moving inside
296
+ `analyze`, and the three reports added beside it, come to 3.2% of the run
297
+ together. Git enrichment, the other suspect, is 11.6%: real, but not a phase to
298
+ rewrite. The dependents pass and path normalisation are below a millisecond
299
+ each.
300
+
301
+ One phase clears 15%: `RailsSourceExtractor`, at 52.6%. Read it with the
302
+ fixture's shape, though. 119 of 147 units *are* framework sources here, so this
303
+ figure is a property of a fixture app with almost no application code, not a
304
+ finding about a real host. Turning it off is one flag
305
+ (`include_framework_sources`), and it does not touch the incremental path at
306
+ all. Filed as B-187 rather than acted on here: sizing it needs a host where
307
+ framework sources are the minority.
308
+
309
+ ### Measured at scale
310
+
311
+ The numbers above are fixture-app numbers. Below are the same measurements on a
312
+ **1,940-unit app**: `apps/rails-8.0-large` in
313
+ [woods-testbed](https://github.com/lost-in-the/woods-testbed), a hand-written
314
+ kernel covering all 35 extractors plus a deterministically generated tree, run by
315
+ `scripts/woods_bench.rb` in woods-testbed (Ruby 3.3.1 / Rails 8.0.5, in-container, 5 reps per
316
+ scenario). See [woods-testbed#2](https://github.com/lost-in-the/woods-testbed/issues/2).
317
+
318
+ Cold full extraction: **5,541 ms**, and the phase split is the surprise:
319
+
320
+ | Phase | ms | share |
321
+ |---|---|---|
322
+ | `write_and_publish` | 2,925 | 53% |
323
+ | extraction | 2,556 | 46% |
324
+ | graph analysis (PageRank + structural) | 27.8 | 0.5% |
325
+ | dedupe | 12.5 | 0.2% |
326
+ | git enrichment | 10.6 | 0.2% |
327
+ | path normalisation | 4.6 | 0.1% |
328
+ | dependents resolution | 4.3 | 0.1% |
329
+
330
+ **PageRank and the dependents pass do not dominate.** Together they are 32 ms of
331
+ 5,541, six tenths of one percent. The cost is extraction itself plus *writing
332
+ the output*, and the latter is dominated by `AtomicFile`'s fsync per unit file.
333
+ Anyone optimising the graph passes here would be tuning 0.5% of the runtime; the
334
+ lever is the write path.
335
+
336
+ Incremental, per scenario, with the units each change causes to be rewritten:
337
+
338
+ | Change | p50 | p95 | Units written | % of index |
339
+ |---|---|---|---|---|
340
+ | a controller | 274 ms | 341 ms | 6 | 0.3% |
341
+ | a model | 402 ms | 451 ms | 38 | 2.0% |
342
+ | **`config/routes.rb`** | **2,534 ms** | 2,830 ms | **1,036** | **53.4%** |
343
+ | `db/schema.rb` | 107 ms | 134 ms | 0 | 0.0% |
344
+
345
+ The routes row is the wholesale re-run of `ROUTE_CONSUMER_EXTRACTORS`. Read the
346
+ **shape** alongside the size: 53.4% is higher than the ~24% measured on a
347
+ production host, because the testbed's generated tree is deliberately dense in
348
+ controllers and view templates, exactly the route-consumer types. A real app
349
+ with more models per controller sits lower. Any figure quoted from that variant
350
+ therefore carries its scale *and* its composition, which is why the harness
351
+ embeds the generator manifest in every result.
352
+
353
+ `db/schema.rb` writing zero units is correct, not a gap: `ReloadPolicy`
354
+ classifies it `:restart`, and a plain `extract_changed` touches nothing because
355
+ the models are class-based and their constants have not changed.
356
+
357
+ **Still not measured:** event latency across a **macOS** Docker Desktop bind
358
+ mount. A Linux bind mount measures 723–824 ms from write to generation bump, but
359
+ osxfs/gRPC-FUSE is the behaviour actually in question and needs a macOS host, so that gap stays open rather than being closed with a Linux number.
360
+
361
+ ## The freshness contract
362
+
363
+ A daemon that keeps the index current is only half the problem. The other half
364
+ is a reader that notices.
365
+
366
+ ### Generations
367
+
368
+ Every extraction mode, full, incremental, targeted refresh, daemon cycle, writes `tmp/woods/generation.json` as its **last** action:
369
+
370
+ ```json
371
+ { "number": 42, "token": "9f2c81ad3e4b7c05", "updated_at": "2026-07-27T04:55:12Z", "reason": "incremental" }
372
+ ```
373
+
374
+ Two properties, both from the same rule, never advance a cursor over work that
375
+ didn't land:
376
+
377
+ - **Bumped last**, so a reader that sees generation N knows N's files are
378
+ already on disk.
379
+ - **Not bumped on failure or on a no-op run**, so staleness stays honest.
380
+
381
+ `IndexReader` checks it at the top of every read: one `File.stat` of a
382
+ ~100-byte file, with caches dropped only when the generation number actually
383
+ advanced. The stat signature is `[mtime, size, inode]`, the inode is
384
+ load-bearing, because two same-second bumps with an identical payload length
385
+ are the daemon's steady state and `[mtime, size]` alone cannot tell them apart
386
+ on a coarse-mtime filesystem. Since `AtomicFile` renames a fresh tempfile on
387
+ every publish the inode always moves, so in practice the file is re-parsed once
388
+ per publish; the saving is on the reads *between* publishes, which is the
389
+ common case. That makes the MCP `reload` tool an
390
+ *optimization* rather than a correctness requirement, previously a long-lived
391
+ server held whatever it read at boot, so an agent working alongside a running
392
+ extraction silently got answers describing the tree as of the last server
393
+ start.
394
+
395
+ An index with no generation file (written before this existed, or by a third
396
+ party) behaves exactly as it always did.
397
+
398
+ ### `woods_status`
399
+
400
+ ```jsonc
401
+ { "index": {
402
+ "generation": 42,
403
+ "generation_reason": "incremental",
404
+ "generation_updated_at": "2026-07-27T04:55:12Z",
405
+ "git_sha_matches_head": true,
406
+ "working_tree_dirty": true, // git_sha_matches_head only sees committed HEAD
407
+ "working_tree_fingerprint": "3f9a2c81ad3e4b7c",
408
+ "staleness_seconds": 12
409
+ },
410
+ "watch": {
411
+ "state": "degraded", // running | degraded | stopped | absent
412
+ "reason": "SyntaxError: unexpected end-of-input",
413
+ "generation": 41
414
+ } }
415
+ ```
416
+
417
+ `working_tree_dirty` closes a real hole: an agent forty uncommitted edits deep
418
+ was told the index matched HEAD while every answer described the tree before
419
+ those edits.
420
+
421
+ The fingerprint (a digest of `git status --porcelain`) is *as of the call*.
422
+ Nothing records the digest the index was built at, so it cannot tell you "this
423
+ is the same dirty state the index describes", it gives a stable identity for
424
+ the current dirty state, so two of your own calls can be compared to detect the
425
+ tree moving underneath you. Pair it with `generation` to distinguish "tree
426
+ changed and the index followed" from "tree changed and the index has not caught
427
+ up".
428
+
429
+ ### Multi-file read consistency
430
+
431
+ The index is a directory, not a file, so "read the index" is many reads. Two
432
+ options were on the table.
433
+
434
+ **Per-request generation re-check, implemented.** Each read checks the
435
+ generation first, so an *unpinned* read never serves from a cache older than
436
+ what is published. `IndexReader#with_pinned_generation` extends that across a
437
+ sequence: freshness is checked once on entry and then held, so nothing already
438
+ cached is dropped and re-read at a newer generation partway through. `warmup!`
439
+ uses it.
440
+
441
+ The pin is the deliberate exception to the sentence above, and it is reader-wide
442
+ rather than per-request: while any pin is held, `refresh_if_stale` returns early
443
+ and *every* read on that reader, including ones outside the pinned block, under
444
+ a threaded transport, is served at the pinned generation. Pins are refcounted,
445
+ so invalidation resumes when the last one releases. Consistency within a
446
+ sequence is bought with bounded staleness across concurrent ones; for a
447
+ development-time index that is the right side of the trade, but it is a trade.
448
+
449
+ Its documented limit: pinning suppresses invalidation, it does not snapshot. An
450
+ artifact never read before is still loaded from disk as it stands when the
451
+ block reaches it. Guaranteeing more would mean materializing the whole index on
452
+ entry, which is what `warmup!` costs, per request.
453
+
454
+ ### Payload publishing
455
+
456
+ **Atomic pointer over the whole payload, adopted.** Every writer now
457
+ publishes into `payloads/gen-<N>/` (`Woods::PayloadStore`) instead of
458
+ `tmp/woods/models/…` directly, and `generation.json` carries a `payload`
459
+ pointer naming which directory the current generation lives in
460
+ (`Woods::Generation#payload_dir`). A reader resolves every artifact through
461
+ that one pointer, so a single atomic write of `generation.json` is the commit
462
+ point for the whole payload, no reader can see a manifest from generation
463
+ N+1 next to a unit from N. An index written before this existed (or a
464
+ third-party writer that still writes flat) has no `payload` key, and every
465
+ reader falls back to the index root unchanged.
466
+
467
+ Retention does not invalidate an in-flight read. While
468
+ `IndexReader#with_pinned_generation` serves a payload, it holds a shared
469
+ advisory lock on that generation's `manifest.json`; pruning takes an exclusive
470
+ non-blocking lock on the same file and skips a busy generation. This works
471
+ across Index MCP and extraction processes without reader-created lease files,
472
+ so read-only index mounts remain sufficient for ordinary tools and a crashed
473
+ reader leaves no stale lease. A skipped directory can temporarily exceed
474
+ `WOODS_PAYLOAD_RETENTION` and is reconsidered by the next successful publish.
475
+ The protocol relies on the same filesystem advisory-lock support as Woods'
476
+ pipeline coordination.
477
+
478
+ A **full** extraction (`Extractor#extract_all`) degrades to a flat publish if
479
+ it can't open a fresh payload directory, the write set is the whole app, so a
480
+ flat publish is still a complete index. An **incremental** run
481
+ (`extract_changed` / `refresh`) writes only the units it touched, so there is
482
+ no complete flat index it could fall back to: over a payload-born index it
483
+ raises `Woods::ExtractionError` instead of publishing a corrupt-looking
484
+ mixture. The generation is never bumped over a raised run, so readers keep
485
+ serving the last good index. See `Extractor#begin_payload!(strict:)`.
486
+
487
+ ### MCP `resources/updated`: evaluated, not implemented
488
+
489
+ The MCP spec supports server-initiated `notifications/resources/updated`, and
490
+ #164 asked whether it is worth adding as a push channel. It is not, yet:
491
+
492
+ - The `mcp` gem gives the server `notify_resources_list_changed` but no
493
+ `notify_resources_updated`, and gates the method behind a
494
+ `resources.subscribe` capability with no handler hooks for
495
+ `resources/subscribe` / `unsubscribe`.
496
+ - The index server runs over stdio as a request/response loop. Pushing would
497
+ mean writing unsolicited frames from a background thread while the main loop
498
+ reads stdin.
499
+ - Client support is not something we could depend on anyway, so it would be
500
+ strictly additive on top of a reader-side check that already delivers the
501
+ correctness property for every client.
502
+
503
+ The reader-side generation check is the robust answer, and it is the one
504
+ implemented. Revisit if the gem grows the server-side API and a client we care
505
+ about acts on it.
506
+
507
+ ## Multiple worktrees
508
+
509
+ The topology to design for: a worktree manager provisions a canonical checkout
510
+ plus N agent slots (commonly ~5), each an independent `Rails.root` with its own
511
+ container stack and its own `tmp/woods`, while several sessions run
512
+ concurrently, sometimes sharing a worktree.
513
+
514
+ ### Disjointness is structural
515
+
516
+ Per-worktree output directories mean daemons never contend *across* worktrees.
517
+ There is deliberately **no** shared cross-worktree index and **no** daemon
518
+ multiplexing several worktrees from one process: the single-active-project
519
+ failure mode of stateful multiplexed servers is well documented in adjacent
520
+ tools, and disjoint-by-construction is what makes this design safe.
521
+
522
+ ### Within one worktree, writers serialize
523
+
524
+ Three writers can want the same index: the daemon, a manual `woods:extract`,
525
+ and a hook-triggered `woods:incremental`. They share the existing file-based
526
+ `PipelineLock`, and the policy is:
527
+
528
+ | Situation | Behaviour |
529
+ |---|---|
530
+ | Daemon cycle while another writer holds the lock | Daemon yields, publishes a `contended` degraded status, and **carries its paths into the next cycle** so nothing is lost |
531
+ | Manual `woods:extract` / `woods:incremental` / `woods:refresh` (including `woods:extract_framework`) | Waits up to `LOCK_STALE_TIMEOUT` (600 s; override with `WOODS_LOCK_WAIT`) for the lock, then **exits non-zero** rather than proceeding unlocked. It also exits non-zero with a typed error when the final generation marker cannot be published; the previous generation remains readable. A storm-triggered `extract_all` can hold the lock for minutes on a large host, and two concurrent writers rewrite the dependency graph from divergent copies, so the loser's work is silently discarded under a generation that says "fresh" |
532
+ | Hook sync on a tree a daemon is already watching | Skips entirely: the daemon has already seen those changes. `WOODS_IGNORE_WATCH=1` overrides |
533
+
534
+ A hook can check cheaply:
535
+
536
+ ```bash
537
+ bundle exec rake woods:watch_status || start_the_daemon # exit 0 = alive
538
+ ```
539
+
540
+ The check does not boot Rails. Without `WOODS_OUTPUT`, it resolves
541
+ `tmp/woods/watch_status.json` relative to the active Rakefile, not the
542
+ launcher's current directory, so `rake -f /app/Rakefile woods:watch_status`
543
+ and worktree-manager invocations inspect the same per-app status. Set
544
+ `WOODS_OUTPUT` when the daemon uses a non-default index directory.
545
+
546
+ Liveness needs three things to agree, each ruling out a different way the
547
+ status file lies: a state a live daemon writes, a pid that still exists (a
548
+ `kill -9` leaves the file behind), and a recent timestamp (a machine that lost
549
+ power leaves a `running` record whose pid some unrelated process now owns).
550
+
551
+ One known limit: the pid check sees only the caller's own pid namespace. In the
552
+ Docker layout, daemon in the container, output volume-mounted to the host, a
553
+ host-side `watch_status` tests a host pid that has nothing to do with the
554
+ containerized daemon, so it can misread liveness in either direction for up to
555
+ `STALE_AFTER` (the timestamp check still bounds it, and the heartbeat keeps a
556
+ live daemon inside that bound). Run `watch_status` on the same side as the
557
+ daemon; a cross-namespace liveness protocol isn't worth its complexity here.
558
+
559
+ ### Hooks for agent sessions
560
+
561
+ The daemon covers a human's editor session. A `claude -p` run in a worktree
562
+ with no daemon needs a different trigger, so the Woods plugin ships two
563
+ hooks (`plugin/hooks/hooks.json`), both shipped disabled:
564
+
565
+ | Hook | When | What it does |
566
+ |---|---|---|
567
+ | `PostToolUse` (`Edit`, `Write`, `MultiEdit`), async | An edit under `app/models`, `config/routes*`, `db/migrate`, `db/*_migrate`, `db/schema.rb`, `db/structure.sql`, or any `package.yml` / `packwerk.yml` | Appends the path to `hook-pending.txt` under a lock, then runs `CHANGED_FILES=<paths> woods:incremental` for whatever is pending, output to `hook.log` |
568
+ | `SessionStart` (`startup`, `resume`) | Session begins | Prints a warning when `generation.json`'s `updated_at` predates `git log -1` |
569
+
570
+ Both read `cwd` from the hook payload, not `CLAUDE_PROJECT_DIR`, which stays
571
+ at the launch root inside a worktree. Both do nothing until
572
+ `tmp/woods/generation.json` exists, and neither runs at all until
573
+ `WOODS_HOOKS_ENABLED=1` is set; `WOODS_HOOKS_DISABLED=1` turns them back off
574
+ without touching that setting. `woods:incremental` still stands down under a
575
+ `:running` daemon, so a hook and a daemon on the same worktree never
576
+ contend. `WOODS_HOOK_RAKE` sets the command prefix (Docker:
577
+ `docker compose exec -T app bundle exec rake`); `WOODS_OUTPUT` points the
578
+ hooks at a non-default index directory, the same variable
579
+ `woods:incremental`/`woods:watch_status` already read.
580
+
581
+ A hook invocation that finds another one already draining the pending file
582
+ does not wait for it: it appends its own path and returns, and the
583
+ in-progress drainer picks that path up on its next pass, looping until a
584
+ drain comes back empty. The one gap this leaves is an append that lands
585
+ between the drainer's last (empty) drain and its releasing the lock: that
586
+ edit is delayed to the next graph-changing edit rather than lost outright,
587
+ and the `SessionStart` warning is the backstop for it.
588
+
589
+ On a host without `flock`, the mkdir-based fallback lock has no kernel-enforced
590
+ release, so a hook killed mid-drain would otherwise leave a lock directory
591
+ behind forever; each lock directory is reclaimed once its mtime is older than
592
+ `WOODS_HOOK_LOCK_STALE_SECONDS` (default 1800), while a fresh one is still
593
+ respected as busy.
594
+ The age check needs `stat`; on a host with neither `flock` nor `stat`, a crashed
595
+ pending-lock holder can still make the next hook wait until the hook timeout.
596
+
597
+ The `SessionStart` warning compares two commit-adjacent timestamps only:
598
+ the generation's `updated_at` against the last commit's time. It says
599
+ nothing about uncommitted changes in the working tree, and a checkout
600
+ sitting on an older commit than the one that produced the generation can
601
+ still read as fresh under this check. Treat a quiet session start as "not
602
+ behind the last commit," not as a general freshness guarantee.
603
+
604
+ ### Reader multiplicity is free
605
+
606
+ Several sessions in one worktree each spawn their own stdio `woods-mcp`. With
607
+ the generation check they converge on fresh data with no coordination and no
608
+ shared server, which is the property worth protecting, since a persisted
609
+ index served by many cheap readers is exactly what Woods has that a
610
+ per-process language-server index does not.
611
+
612
+ ### Idle TTL
613
+
614
+ N resident daemons is N booted apps, and most slots are dormant most of the
615
+ time. `idle_timeout` (off by default) stops a daemon after that many seconds
616
+ without a file event, so a slot nobody is working in stops holding ~65 MB. A
617
+ worktree hook or session start revives it.
618
+
619
+ ```ruby
620
+ Woods::Watch::Daemon.new(output_dir: …, idle_timeout: 900).run # 15 minutes
621
+ ```
622
+
623
+ Off by default because a single-worktree host wants the daemon to stay up.
624
+
625
+ ### What is verified, and what isn't
626
+
627
+ | Property | Where |
628
+ |---|---|
629
+ | A real file write reaches `extract_changed`, watcher thread, callback, debounce and drain loop end to end | `spec/watch/watcher_integration_spec.rb` |
630
+ | A burst coalesces into one extraction; a same-second rewrite is not lost; a `stop` racing startup is honoured | same |
631
+ | A real `Rails.application.reloader` picks up changed source, under the interlock unload lock | `spec/integration/watch_daemon_spec.rb` |
632
+ | Concurrent cycles serialize; no deadlock; no orphaned lock, even when extraction raises | `spec/watch/multi_instance_spec.rb` |
633
+ | Contended cycle carries its paths forward | same |
634
+ | Idle TTL exits and records why | same |
635
+ | Six real worktrees stay disjoint, validate-green, independently versioned | `spec/integration/multi_worktree_spec.rb` |
636
+ | Many concurrent readers per worktree converge without coordination | same |
637
+
638
+ **Per-daemon memory at six-worktree scale, measured.** `Rails.root` is a
639
+ process singleton, so six *concurrently extracting* booted apps cannot exist in
640
+ one Ruby process. That constraint is about processes, not containers, so six
641
+ forks with disjoint output directories satisfy it, `scripts/woods_daemon_scale_smoke.rb` in woods-testbed does exactly that:
642
+
643
+ | | |
644
+ |---|---|
645
+ | Parent booted app, no extraction | 83.3 MB |
646
+ | Per worktree after two full extractions | 165.9 – 166.3 MB (mean **166.0**) |
647
+ | Summed across six | 996.2 MB |
648
+
649
+ **The 72.1 MB figure above is a fixture-app number; at 1,940 units it is 166 MB, 2.3× that.** Plan for the measured figure, not the extrapolation.
650
+
651
+ Two caveats the harness prints itself: forks share the parent heap
652
+ copy-on-write, so the 996 MB sum is an upper bound rather than true additional
653
+ memory and the mean is the per-daemon figure; and this measures repeated
654
+ `Extractor` cycles, so it is the extraction footprint rather than the idle
655
+ steady state a dormant daemon holds.
656
+
657
+ ## Embedding it
658
+
659
+ ```ruby
660
+ # A host that owns its own event loop
661
+ daemon = Woods::Watch::Daemon.new(output_dir: Rails.root.join("tmp/woods"))
662
+ result = daemon.process(changed_paths)
663
+ # => { action: :incremental, state: :running, generation: 42, count: 1, duration_ms: 61 }
664
+ ```
665
+
666
+ `#process` is one whole cycle and is the supported embedding point. `#run` only
667
+ supplies batches to it.