woods 2.0.0.beta2 → 2.0.0.beta4

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +339 -1
  3. data/CONTRIBUTING.md +188 -12
  4. data/README.md +93 -174
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +109 -8
  7. data/docs/AGENT_SETUP.md +98 -7
  8. data/docs/BACKEND_MATRIX.md +25 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +267 -16
  11. data/docs/CONSOLE_MCP_SETUP.md +80 -7
  12. data/docs/DOCKER_SETUP.md +22 -3
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +45 -6
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +147 -7
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +276 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +37 -22
  22. data/docs/MCP_WORKTREE_SETUP.md +43 -83
  23. data/docs/NOTION_INTEGRATION.md +13 -0
  24. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  25. data/docs/PUBLISHED_INDEX.md +72 -0
  26. data/docs/README.md +7 -0
  27. data/docs/RETRIEVAL_GUIDE.md +273 -12
  28. data/docs/RUNTIME_TRACING.md +71 -0
  29. data/docs/SOURCE_FRESHNESS.md +143 -0
  30. data/docs/TROUBLESHOOTING.md +129 -18
  31. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  32. data/docs/UPGRADING_TO_2.md +48 -22
  33. data/docs/WATCH_DAEMON.md +277 -67
  34. data/exe/woods-agent-config +6 -0
  35. data/exe/woods-extract +5 -0
  36. data/exe/woods-hook-context +6 -0
  37. data/exe/woods-mcp-start +14 -9
  38. data/lib/generators/woods/pgvector_generator.rb +8 -2
  39. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  40. data/lib/tasks/woods.rake +47 -397
  41. data/lib/woods/agent_configuration/applier.rb +135 -0
  42. data/lib/woods/agent_configuration/cli.rb +101 -0
  43. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  44. data/lib/woods/agent_configuration/document.rb +105 -0
  45. data/lib/woods/agent_configuration/error.rb +7 -0
  46. data/lib/woods/agent_configuration/launcher.rb +75 -0
  47. data/lib/woods/agent_configuration/layout.rb +72 -0
  48. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  49. data/lib/woods/agent_configuration/plan.rb +98 -0
  50. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  51. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  52. data/lib/woods/agent_configuration/planner.rb +63 -0
  53. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  54. data/lib/woods/agent_configuration/preflight.rb +100 -0
  55. data/lib/woods/agent_configuration/recovery.rb +49 -0
  56. data/lib/woods/ast/node.rb +2 -0
  57. data/lib/woods/ast/parser.rb +38 -5
  58. data/lib/woods/builder.rb +21 -5
  59. data/lib/woods/cache/cache_middleware.rb +28 -7
  60. data/lib/woods/cache/cache_store.rb +4 -5
  61. data/lib/woods/change_set.rb +5 -4
  62. data/lib/woods/console/credential_index.rb +20 -2
  63. data/lib/woods/console/credential_scanner.rb +18 -17
  64. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  65. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  66. data/lib/woods/console/embedded_executor.rb +32 -10
  67. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  68. data/lib/woods/console/rack_middleware.rb +22 -13
  69. data/lib/woods/console/server.rb +18 -16
  70. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  71. data/lib/woods/console/sql_table_scanner.rb +47 -7
  72. data/lib/woods/console/sql_validator.rb +49 -9
  73. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  74. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  75. data/lib/woods/dependency_graph.rb +65 -13
  76. data/lib/woods/embedding/corpus.rb +94 -0
  77. data/lib/woods/embedding/indexer.rb +114 -60
  78. data/lib/woods/embedding/openai.rb +17 -6
  79. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  80. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  81. data/lib/woods/export/typed_reader.rb +56 -0
  82. data/lib/woods/extractor.rb +277 -149
  83. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  84. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  85. data/lib/woods/extractors/caching_extractor.rb +3 -1
  86. data/lib/woods/extractors/concern_extractor.rb +64 -6
  87. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  88. data/lib/woods/extractors/controller_extractor.rb +13 -4
  89. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  90. data/lib/woods/extractors/declared_parent.rb +55 -0
  91. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  92. data/lib/woods/extractors/engine_extractor.rb +3 -1
  93. data/lib/woods/extractors/event_extractor.rb +4 -2
  94. data/lib/woods/extractors/factory_extractor.rb +3 -1
  95. data/lib/woods/extractors/graphql_extractor.rb +10 -13
  96. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  97. data/lib/woods/extractors/job_extractor.rb +6 -19
  98. data/lib/woods/extractors/lib_extractor.rb +13 -9
  99. data/lib/woods/extractors/mailer_extractor.rb +26 -15
  100. data/lib/woods/extractors/manager_extractor.rb +3 -1
  101. data/lib/woods/extractors/method_parameters.rb +53 -0
  102. data/lib/woods/extractors/middleware_argument.rb +65 -0
  103. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  104. data/lib/woods/extractors/migration_extractor.rb +3 -1
  105. data/lib/woods/extractors/model_extractor.rb +26 -34
  106. data/lib/woods/extractors/package_extractor.rb +24 -4
  107. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  108. data/lib/woods/extractors/policy_extractor.rb +3 -1
  109. data/lib/woods/extractors/poro_extractor.rb +13 -9
  110. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  111. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  112. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  113. data/lib/woods/extractors/route_extractor.rb +3 -1
  114. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  115. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  116. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  117. data/lib/woods/extractors/service_extractor.rb +3 -1
  118. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  119. data/lib/woods/extractors/shared_utility_methods.rb +48 -19
  120. data/lib/woods/extractors/source_nesting.rb +1 -1
  121. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  122. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  123. data/lib/woods/extractors/validator_extractor.rb +3 -1
  124. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  125. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  126. data/lib/woods/gem_mapper.rb +2 -0
  127. data/lib/woods/git_history.rb +116 -0
  128. data/lib/woods/graph_analyzer.rb +35 -6
  129. data/lib/woods/hooks/context_cli.rb +54 -0
  130. data/lib/woods/hooks/context_event.rb +88 -0
  131. data/lib/woods/hooks/context_hint.rb +73 -0
  132. data/lib/woods/hooks/context_impact.rb +77 -0
  133. data/lib/woods/hooks/context_output.rb +47 -0
  134. data/lib/woods/hooks/context_state.rb +102 -0
  135. data/lib/woods/hooks/refresh.rb +79 -0
  136. data/lib/woods/hooks/rule_projection.rb +78 -0
  137. data/lib/woods/input_rules.rb +19 -0
  138. data/lib/woods/mcp/bearer_auth.rb +22 -13
  139. data/lib/woods/mcp/bootstrapper.rb +79 -4
  140. data/lib/woods/mcp/config_resolver.rb +2 -1
  141. data/lib/woods/mcp/index_reader.rb +334 -162
  142. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  143. data/lib/woods/mcp/origin_guard.rb +17 -9
  144. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  145. data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
  146. data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
  147. data/lib/woods/mcp/search_results.rb +74 -0
  148. data/lib/woods/mcp/server.rb +178 -63
  149. data/lib/woods/mcp/tool_contract.rb +3 -1
  150. data/lib/woods/mcp/tool_response_renderer.rb +41 -0
  151. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  152. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  153. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  154. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  155. data/lib/woods/mcp/traversal_response.rb +22 -0
  156. data/lib/woods/notion/exporter.rb +56 -17
  157. data/lib/woods/obsidian/destination_plan.rb +98 -0
  158. data/lib/woods/obsidian/name_mapper.rb +19 -3
  159. data/lib/woods/obsidian/note_builder.rb +19 -10
  160. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  161. data/lib/woods/operator/pipeline_guard.rb +18 -13
  162. data/lib/woods/path_dispatcher.rb +13 -6
  163. data/lib/woods/payload_store.rb +27 -26
  164. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  165. data/lib/woods/published_index.rb +2 -2
  166. data/lib/woods/railtie.rb +3 -3
  167. data/lib/woods/railtie_support.rb +12 -12
  168. data/lib/woods/rake_helpers.rb +382 -0
  169. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  170. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  171. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  172. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  173. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  174. data/lib/woods/resilience/index_validator.rb +112 -23
  175. data/lib/woods/retrieval/context_assembler.rb +50 -15
  176. data/lib/woods/retrieval/lexical_assembler.rb +84 -0
  177. data/lib/woods/retrieval/lexical_index.rb +120 -0
  178. data/lib/woods/retrieval/ranker.rb +4 -2
  179. data/lib/woods/retrieval/scope.rb +108 -0
  180. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  181. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  182. data/lib/woods/retrieval/search_executor.rb +86 -27
  183. data/lib/woods/retrieval/source_evidence.rb +200 -0
  184. data/lib/woods/retriever.rb +98 -22
  185. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  186. data/lib/woods/session_tracer/file_store.rb +6 -1
  187. data/lib/woods/session_tracer/middleware.rb +10 -12
  188. data/lib/woods/session_tracer/redis_store.rb +22 -6
  189. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  190. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  191. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  192. data/lib/woods/source_inputs/consumer_errors.rb +31 -0
  193. data/lib/woods/source_inputs/handoff.rb +102 -0
  194. data/lib/woods/source_inputs/launcher.rb +157 -0
  195. data/lib/woods/source_inputs/manifest.rb +124 -0
  196. data/lib/woods/source_inputs/private_key.rb +55 -0
  197. data/lib/woods/source_inputs/scanner.rb +171 -0
  198. data/lib/woods/source_inputs/scopes.rb +71 -0
  199. data/lib/woods/source_inputs/session.rb +214 -0
  200. data/lib/woods/source_inputs/status.rb +84 -0
  201. data/lib/woods/source_inputs/verifier.rb +107 -0
  202. data/lib/woods/storage/metadata_store.rb +25 -25
  203. data/lib/woods/storage/pgvector.rb +35 -10
  204. data/lib/woods/storage/qdrant.rb +17 -7
  205. data/lib/woods/storage/vector_store.rb +18 -6
  206. data/lib/woods/tasks.rb +3 -2
  207. data/lib/woods/temporal/json_snapshot_store.rb +58 -9
  208. data/lib/woods/unblocked/exporter.rb +59 -70
  209. data/lib/woods/version.rb +1 -1
  210. data/lib/woods/watch/boot_snapshot.rb +52 -0
  211. data/lib/woods/watch/daemon.rb +154 -32
  212. data/lib/woods/watch/listen_watcher.rb +4 -0
  213. data/lib/woods/watch/polling_watcher.rb +5 -1
  214. data/lib/woods/watch/status.rb +20 -15
  215. data/lib/woods/watch/tree_scan.rb +21 -13
  216. data/lib/woods/watch/watcher.rb +4 -1
  217. data/lib/woods.rb +50 -11
  218. data/plugin/.claude-plugin/plugin.json +1 -1
  219. data/plugin/hooks/adapters/normalize.jq +15 -0
  220. data/plugin/hooks/adapters/normalize.rb +63 -0
  221. data/plugin/hooks/hooks.json +20 -0
  222. data/plugin/hooks/woods-context.sh +50 -0
  223. data/plugin/hooks/woods-input-rules.sh +159 -0
  224. data/plugin/hooks/woods-opencode.mjs +65 -0
  225. data/plugin/hooks/woods-post-edit.sh +2 -225
  226. data/plugin/hooks/woods-refresh.sh +260 -0
  227. data/plugin/hooks/woods-session-start.sh +47 -55
  228. data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
  229. data/plugin/skills/woods-diagnose/SKILL.md +319 -1
  230. data/plugin/skills/woods-investigate/SKILL.md +145 -0
  231. data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
  232. data/plugin/skills/woods-setup/SKILL.md +110 -6
  233. metadata +87 -5
@@ -0,0 +1,159 @@
1
+ # Generated by script/generate-hook-rules; do not edit.
2
+ woods_input_action() {
3
+ local path="$1" operation="${2:-update}"
4
+ if [[ "$path" == Gemfile ]]; then printf full; return; fi
5
+ if [[ "$path" == Gemfile.lock ]]; then printf full; return; fi
6
+ if [[ "$path" == .ruby-version ]]; then printf full; return; fi
7
+ if [[ "$path" == config/application.rb ]]; then printf full; return; fi
8
+ if [[ "$path" == config/boot.rb ]]; then printf full; return; fi
9
+ if [[ "$path" == config/environment.rb ]]; then printf full; return; fi
10
+ if [[ "$path" == config/database.yml ]]; then printf full; return; fi
11
+ if [[ "$path" == config/credentials.yml.enc ]]; then printf full; return; fi
12
+ if [[ "$path" == config/master.key ]]; then printf full; return; fi
13
+ if [[ "$path" == db/schema.rb ]]; then printf full; return; fi
14
+ if [[ "$path" == db/structure.sql ]]; then printf full; return; fi
15
+ if [[ "$path" == config/initializers/* ]]; then printf full; return; fi
16
+ if [[ "$path" == config/environments/* ]]; then printf full; return; fi
17
+ if [[ "$path" == config/credentials/* ]]; then printf full; return; fi
18
+ if [[ "$path" =~ ^config/settings\.ya?ml$ ]]; then printf full; return; fi
19
+ if [[ "$path" =~ ^config/settings/[^/]+\.ya?ml$ ]]; then printf full; return; fi
20
+ if [[ "$path" =~ ^config/(cable|storage|sidekiq|puma|cache|queue)\.ya?ml$ ]]; then printf full; return; fi
21
+ if [[ "$path" =~ ^\.env(\..+)?$ ]]; then printf full; return; fi
22
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/* ]]; }; }; }; then
23
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
24
+ return
25
+ fi
26
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/services/* ]] || [[ "$path" == app/interactors/* ]] || [[ "$path" == app/operations/* ]] || [[ "$path" == app/commands/* ]] || [[ "$path" == app/use_cases/* ]]; }; }; }; then
27
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
28
+ return
29
+ fi
30
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/jobs/* ]] || [[ "$path" == app/workers/* ]] || [[ "$path" == app/sidekiq/* ]]; }; }; }; then
31
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
32
+ return
33
+ fi
34
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/serializers/* ]] || [[ "$path" == app/blueprinters/* ]] || [[ "$path" == app/decorators/* ]]; }; }; }; then
35
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
36
+ return
37
+ fi
38
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/managers/* ]]; }; }; }; then
39
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
40
+ return
41
+ fi
42
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/policies/* ]]; }; }; }; then
43
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
44
+ return
45
+ fi
46
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/validators/* ]]; }; }; }; then
47
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
48
+ return
49
+ fi
50
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/policies/* ]]; }; }; }; then
51
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
52
+ return
53
+ fi
54
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/decorators/* ]] || [[ "$path" == app/presenters/* ]] || [[ "$path" == app/form_objects/* ]]; }; }; }; then
55
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
56
+ return
57
+ fi
58
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == config/initializers/* ]] || [[ "$path" == config/environments/* ]]; }; }; }; then
59
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
60
+ return
61
+ fi
62
+ if { { { [[ "$path" == *.rb ]]; } && [[ "$path" == */concerns/* ]] && { [[ "$path" == app/* ]]; }; }; }; then
63
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
64
+ return
65
+ fi
66
+ if { { [[ "$path" != */concerns/* ]] && { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/* ]] || [[ "$path" == lib/* ]]; }; }; }; then
67
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
68
+ return
69
+ fi
70
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/graphql/* ]]; }; }; }; then
71
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
72
+ return
73
+ fi
74
+ if { { { [[ "$path" == *.yml ]]; } && { [[ "$path" == config/locales/* ]]; }; }; }; then
75
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
76
+ return
77
+ fi
78
+ if { { { [[ "$path" == *.html.erb ]] || [[ "$path" == *.erb ]]; } && { [[ "$path" == app/views/* ]]; }; }; }; then
79
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
80
+ return
81
+ fi
82
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "${path%/*}" == db/migrate ]]; }; }; }; then
83
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
84
+ return
85
+ fi
86
+ if { { [[ "$path" != */concerns/* ]] && { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/models/* ]]; }; }; }; then
87
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
88
+ return
89
+ fi
90
+ if { { [[ "$path" != */tasks/* ]] && [[ "$path" != */generators/* ]] && { [[ "$path" == *.rb ]]; } && { [[ "$path" == lib/* ]]; }; }; }; then
91
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
92
+ return
93
+ fi
94
+ if { { { [[ "$path" == *_spec.rb ]]; } && { [[ "$path" == spec/* ]]; }; }; }; then
95
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
96
+ return
97
+ fi
98
+ if { { { [[ "$path" == *_test.rb ]]; } && { [[ "$path" == test/* ]]; }; }; }; then
99
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
100
+ return
101
+ fi
102
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/controllers/* ]]; }; }; }; then
103
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
104
+ return
105
+ fi
106
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/models/* ]]; }; }; }; then
107
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
108
+ return
109
+ fi
110
+ if { { { [[ "$path" == *.erb ]]; } && { [[ "$path" == app/views/* ]]; }; }; }; then
111
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
112
+ return
113
+ fi
114
+ if { { { [[ "$path" == *.rake ]]; } && { [[ "$path" == lib/tasks/* ]]; }; }; }; then
115
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
116
+ return
117
+ fi
118
+ if { [[ "$path" == config/routes.rb ]] || { { [[ "$path" == config/routes/* ]]; }; }; }; then
119
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
120
+ return
121
+ fi
122
+ if { [[ "$path" == config/routes.rb ]] || [[ "$path" == Gemfile.lock ]] || { { [[ "$path" == config/routes/* ]]; }; }; }; then
123
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
124
+ return
125
+ fi
126
+ if { [[ "$path" == config/application.rb ]] || [[ "$path" == Gemfile.lock ]] || { { [[ "$path" == config/initializers/* ]] || [[ "$path" == config/environments/* ]]; }; }; }; then
127
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
128
+ return
129
+ fi
130
+ if { [[ "$path" == config/recurring.yml ]] || [[ "$path" == config/sidekiq_cron.yml ]] || [[ "$path" == config/schedule.rb ]] || { false; }; }; then
131
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
132
+ return
133
+ fi
134
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/models/* ]]; }; }; }; then
135
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
136
+ return
137
+ fi
138
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == spec/factories/* ]] || [[ "$path" == test/factories/* ]]; }; }; }; then
139
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
140
+ return
141
+ fi
142
+ if { { { [[ "$path" == *.sql ]]; } && { [[ "$path" == db/views/* ]]; }; }; }; then
143
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
144
+ return
145
+ fi
146
+ if { { { [[ "$path" == *.rb ]]; } && { [[ "$path" == app/* ]]; }; }; }; then
147
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
148
+ return
149
+ fi
150
+ if { [[ "$path" == Gemfile.lock ]] || { false; }; }; then
151
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
152
+ return
153
+ fi
154
+ if { { [[ "$path" != *node_modules/* ]] && [[ "$path" != *vendor/* ]] && [[ "$path" != *tmp/* ]] && [[ "$path" != *bin/* ]] && [[ "$path" != *script/* ]] && { [[ "${path##*/}" == package.yml ]] || [[ "${path##*/}" == packwerk.yml ]]; }; }; }; then
155
+ case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac
156
+ return
157
+ fi
158
+ printf ignore
159
+ }
@@ -0,0 +1,65 @@
1
+ // OpenCode 1.18.27 tool.execute.after adapter. Keep callback metadata private:
2
+ // only the worktree and affected paths cross into the shared refresh runner.
3
+ import { spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+
7
+ const runner = fileURLToPath(new URL("./woods-refresh.sh", import.meta.url));
8
+ const enabled = () => process.env.WOODS_HOOKS_ENABLED === "1" && process.env.WOODS_HOOKS_DISABLED !== "1";
9
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
10
+ const pathString = (value) => typeof value === "string" && value.length > 0 && !value.includes("\0");
11
+
12
+ function eventsFor(input, output) {
13
+ if (!object(input) || !object(output) || !object(output.metadata)) throw new Error("invalid event");
14
+ const metadata = output.metadata;
15
+ if (input.tool === "apply_patch") {
16
+ if (!Array.isArray(metadata.files) || !metadata.files.length || metadata.files.length > 1000) throw new Error("missing file metadata");
17
+ return metadata.files.flatMap((file) => {
18
+ if (!object(file) || !pathString(file.filePath)) throw new Error("invalid file metadata");
19
+ if (file.type === "move") {
20
+ if (!pathString(file.movePath)) throw new Error("missing move target");
21
+ return [{ path: file.filePath, operation: "delete" }, { path: file.movePath, operation: "add" }];
22
+ }
23
+ if (!["add", "update", "delete"].includes(file.type)) throw new Error("unknown file operation");
24
+ return [{ path: file.filePath, operation: file.type }];
25
+ });
26
+ }
27
+ if (input.tool === "write") {
28
+ if (!pathString(metadata.filepath) || typeof metadata.exists !== "boolean") throw new Error("invalid write metadata");
29
+ return [{ path: metadata.filepath, operation: metadata.exists ? "update" : "add" }];
30
+ }
31
+ if (input.tool === "edit") {
32
+ if (!object(metadata.filediff) || !pathString(metadata.filediff.file)) throw new Error("invalid edit metadata");
33
+ return [{ path: metadata.filediff.file, operation: "update" }];
34
+ }
35
+ throw new Error("unsupported tool");
36
+ }
37
+
38
+ export default async function WoodsHooks({ directory, worktree }) {
39
+ return {
40
+ "tool.execute.after": async (input, output) => {
41
+ if (!enabled() || !["apply_patch", "write", "edit"].includes(input?.tool)) return;
42
+ try {
43
+ if (!pathString(directory) || !pathString(worktree) || !path.isAbsolute(directory) || !path.isAbsolute(worktree)) {
44
+ throw new Error("invalid project context");
45
+ }
46
+ const relative = path.relative(worktree, directory);
47
+ if (relative === ".." || relative.startsWith("../") || path.isAbsolute(relative)) throw new Error("foreign project");
48
+ const payload = JSON.stringify({ version: 1, client: "opencode", root: directory, events: eventsFor(input, output) });
49
+ if (Buffer.byteLength(payload) > 49_152) throw new Error("event too large");
50
+ // The shared runner durably queues and owns its own deadline. Tool
51
+ // completion only hands off the event; it does not claim refresh success.
52
+ await new Promise((resolve, reject) => {
53
+ const child = spawn("bash", [runner, "opencode"], {
54
+ cwd: directory, detached: true, stdio: ["pipe", "ignore", "inherit"],
55
+ });
56
+ child.once("error", reject);
57
+ child.stdin.once("error", reject);
58
+ child.stdin.end(payload, () => { child.unref(); resolve(); });
59
+ });
60
+ } catch {
61
+ console.error("[Woods hooks] Unsupported, malformed or undeliverable OpenCode edit event; refresh not confirmed.");
62
+ }
63
+ },
64
+ };
65
+ }
@@ -1,226 +1,3 @@
1
1
  #!/usr/bin/env bash
2
- # Woods PostToolUse hook (#280): after an edit to a graph-changing path,
3
- # refresh the index with `woods:incremental` in the background.
4
- #
5
- # Reads `cwd` from the hook payload, not CLAUDE_PROJECT_DIR, so a session in
6
- # a linked worktree refreshes that worktree's own index. Runs only when an
7
- # index already exists there. `woods:incremental` stands down under a
8
- # running watch daemon and takes the extraction lock itself; the lock here
9
- # only stops this hook from queueing one rake process per keystroke.
10
- #
11
- # Opt-in: this hook is shipped disabled. It does nothing until
12
- # WOODS_HOOKS_ENABLED=1 is set (see docs/WATCH_DAEMON.md and the woods-setup
13
- # skill for where to set it). WOODS_HOOKS_DISABLED=1 turns it back off even
14
- # after it has been enabled, without touching the enable setting.
15
- #
16
- # Lock contention must not drop an edit. A hook invocation that finds the
17
- # run lock busy appends its path to hook-pending.txt and returns immediately
18
- # instead of waiting; whichever invocation is holding the run lock drains
19
- # that file in a loop, passing every path collected on each drain as one
20
- # CHANGED_FILES batch, until a drain comes back empty. The one narrow gap
21
- # this leaves: an append that lands after the holder's last (empty) drain
22
- # but before it releases the run lock sits in the file, unprocessed, until
23
- # the next graph-changing edit triggers a hook that both appends it and
24
- # then wins the now-free run lock itself. That edit is delayed, not lost,
25
- # and the SessionStart hook's staleness warning is the backstop for it.
26
- #
27
- # The mkdir-based lock fallback has no kernel-enforced release: a `flock`
28
- # held by a killed process is freed automatically, but a lock *directory*
29
- # a killed process made is not. A hook killed mid-drain (OOM, a `kill -9`,
30
- # the host restarting) leaves `hook.lock.d` or `hook-pending.lock.d` behind
31
- # forever, so every later hook either skips draining permanently (the run
32
- # lock) or spins until the 600s hook timeout on every single invocation
33
- # (the pending lock, which blocks). Both lock directories are therefore
34
- # reclaimed once their mtime is older than WOODS_HOOK_LOCK_STALE_SECONDS: a
35
- # fresh lock directory is still respected as busy.
36
- #
37
- # Knobs:
38
- # WOODS_HOOK_RAKE command prefix, default "bundle exec rake"
39
- # (Docker: "docker compose exec -T app bundle exec rake")
40
- # WOODS_OUTPUT index directory override, same variable
41
- # woods:incremental/woods:watch_status already read;
42
- # default tmp/woods under the payload's cwd
43
- # WOODS_HOOKS_ENABLED set to 1 to turn the hook on
44
- # WOODS_HOOKS_DISABLED set to 1 to turn it back off
45
- # WOODS_HOOK_LOCK_STALE_SECONDS
46
- # age (mtime) after which a leftover mkdir-based lock
47
- # directory is reclaimed instead of respected as busy;
48
- # default 1800 (a few multiples of the 600s hook
49
- # timeout). Only the mkdir fallback needs this;
50
- # flock has no equivalent problem.
51
- set -u
52
-
53
- [ "${WOODS_HOOKS_DISABLED:-0}" = "1" ] && exit 0
54
- [ "${WOODS_HOOKS_ENABLED:-0}" = "1" ] || exit 0
55
-
56
- payload="$(cat)"
57
-
58
- # $1: dotted key path. Prefers jq, falls back to ruby, else gives up quietly.
59
- field() {
60
- if command -v jq >/dev/null 2>&1; then
61
- printf '%s' "$payload" | jq -r ".$1 // empty"
62
- elif command -v ruby >/dev/null 2>&1; then
63
- printf '%s' "$payload" | ruby -rjson -e '
64
- value = JSON.parse($stdin.read)
65
- ARGV[0].split(".").each { |key| value = value.is_a?(Hash) ? value[key] : nil }
66
- print value.to_s' -- "$1"
67
- else
68
- printf ''
69
- fi
70
- }
71
-
72
- cwd="$(field cwd)"
73
- file="$(field tool_input.file_path)"
74
- [ -z "$cwd" ] && exit 0
75
- [ -z "$file" ] && exit 0
76
-
77
- # Same no-boot resolution woods:watch_status uses: an explicit WOODS_OUTPUT
78
- # wins outright (absolute or relative to cwd), otherwise tmp/woods under cwd.
79
- # This is the config's own override knob, not a second hardcoded path.
80
- configured_output="${WOODS_OUTPUT:-tmp/woods}"
81
- case "$configured_output" in
82
- /*) tmp_dir="$configured_output" ;;
83
- *) tmp_dir="$cwd/$configured_output" ;;
84
- esac
85
-
86
- [ -f "$tmp_dir/generation.json" ] || exit 0
87
-
88
- case "$file" in
89
- "$cwd"/*) rel="${file#"$cwd"/}" ;;
90
- /*) exit 0 ;;
91
- *) rel="$file" ;;
92
- esac
93
-
94
- case "$rel" in
95
- app/models/*|config/routes.rb|config/routes/*|db/migrate/*|db/*_migrate/*|db/schema.rb|db/structure.sql|package.yml|*/package.yml|packwerk.yml) ;;
96
- *) exit 0 ;;
97
- esac
98
-
99
- rake="${WOODS_HOOK_RAKE:-bundle exec rake}"
100
- log="$tmp_dir/hook.log"
101
- pending_file="$tmp_dir/hook-pending.txt"
102
- pending_lock="$tmp_dir/hook-pending.lock"
103
- pending_lock_dir="$tmp_dir/hook-pending.lock.d"
104
- run_lock="$tmp_dir/hook.lock"
105
- run_lock_dir="$tmp_dir/hook.lock.d"
106
-
107
- mkdir -p "$tmp_dir" 2>/dev/null || true
108
-
109
- have_flock() { command -v flock >/dev/null 2>&1; }
110
-
111
- # mtime of a directory in epoch seconds, GNU stat then BSD stat, empty if
112
- # neither exists (treated as "not stale": fail closed, keep waiting rather
113
- # than reclaim on a guess).
114
- dir_mtime() {
115
- stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null
116
- }
117
-
118
- # True when $1 is an mkdir-based lock directory old enough that it can only
119
- # be a crash leftover, never a legitimately still-running hook (a hook run
120
- # is one rake invocation, bounded by the 600s hook timeout).
121
- lock_dir_stale() {
122
- mtime="$(dir_mtime "$1")"
123
- [ -z "$mtime" ] && return 1
124
- now="$(date +%s)"
125
- age=$((now - mtime))
126
- [ "$age" -gt "${WOODS_HOOK_LOCK_STALE_SECONDS:-1800}" ]
127
- }
128
-
129
- # Blocking mkdir-based lock acquire: waits for $1, reclaiming it once stale
130
- # rather than waiting for a crashed holder that will never release it.
131
- acquire_mkdir_lock() {
132
- while ! mkdir "$1" 2>/dev/null; do
133
- if lock_dir_stale "$1"; then
134
- rmdir "$1" 2>/dev/null || true
135
- continue
136
- fi
137
- sleep 0.1
138
- done
139
- }
140
-
141
- release_mkdir_lock() {
142
- rmdir "$1" 2>/dev/null || true
143
- }
144
-
145
- # Non-blocking mkdir-based lock attempt: one reclaim retry when stale, no
146
- # wait otherwise (contention here means "someone else is already draining,"
147
- # not "someone crashed").
148
- try_acquire_mkdir_lock() {
149
- mkdir "$1" 2>/dev/null && return 0
150
- if lock_dir_stale "$1"; then
151
- rmdir "$1" 2>/dev/null || true
152
- mkdir "$1" 2>/dev/null && return 0
153
- fi
154
- return 1
155
- }
156
-
157
- # Append one path to the pending file. Blocking: the critical section is a
158
- # single append, so any wait here is brief regardless of who else holds it.
159
- append_pending() {
160
- if have_flock; then
161
- exec 7>"$pending_lock"
162
- flock 7
163
- printf '%s\n' "$1" >>"$pending_file"
164
- flock -u 7
165
- exec 7>&-
166
- else
167
- acquire_mkdir_lock "$pending_lock_dir"
168
- printf '%s\n' "$1" >>"$pending_file"
169
- release_mkdir_lock "$pending_lock_dir"
170
- fi
171
- }
172
-
173
- # Print and clear whatever is currently pending, empty output if nothing is.
174
- drain_pending() {
175
- if have_flock; then
176
- exec 7>"$pending_lock"
177
- flock 7
178
- if [ -s "$pending_file" ]; then
179
- cat "$pending_file"
180
- : >"$pending_file"
181
- fi
182
- flock -u 7
183
- exec 7>&-
184
- else
185
- acquire_mkdir_lock "$pending_lock_dir"
186
- if [ -s "$pending_file" ]; then
187
- cat "$pending_file"
188
- : >"$pending_file"
189
- fi
190
- release_mkdir_lock "$pending_lock_dir"
191
- fi
192
- }
193
-
194
- run_incremental() {
195
- # A drain can return several paths at once; CHANGED_FILES takes a
196
- # comma-separated list (see lib/tasks/woods.rake).
197
- changed="$(printf '%s\n' "$1" | tr '\n' ',' | sed 's/,$//')"
198
- # shellcheck disable=SC2086
199
- ( cd "$cwd" && CHANGED_FILES="$changed" $rake woods:incremental ) >>"$log" 2>&1
200
- }
201
-
202
- drain_until_empty() {
203
- while :; do
204
- batch="$(drain_pending)"
205
- [ -z "$batch" ] && break
206
- run_incremental "$batch"
207
- done
208
- }
209
-
210
- append_pending "$rel"
211
-
212
- if have_flock; then
213
- exec 8>"$run_lock"
214
- if flock -n 8; then
215
- drain_until_empty
216
- flock -u 8
217
- fi
218
- exec 8>&-
219
- else
220
- if try_acquire_mkdir_lock "$run_lock_dir"; then
221
- drain_until_empty
222
- release_mkdir_lock "$run_lock_dir"
223
- fi
224
- fi
225
-
226
- exit 0
2
+ # Claude PostToolUse adapter; the shared runner owns queueing and refresh.
3
+ exec "$BASH" "${BASH_SOURCE[0]%/*}/woods-refresh.sh" claude