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
@@ -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
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env bash
2
+ # Opt-in refresh dispatcher. Each edit is durable until its task succeeds.
3
+ # See docs/WATCH_DAEMON.md for retry, daemon deferral and Docker transport.
4
+ set -u
5
+ umask 077
6
+ [ "${WOODS_HOOKS_DISABLED:-0}" = 1 ] && exit 0
7
+ [ "${WOODS_HOOKS_ENABLED:-0}" = 1 ] || exit 0
8
+
9
+ client="${1:-}"
10
+ hook_dir="${BASH_SOURCE[0]%/*}"
11
+ # Preserve raw bytes until JSON validation: Bash command substitution silently
12
+ # removes NUL bytes and could turn a malformed path into a different valid one.
13
+ input_file="$(mktemp "${TMPDIR:-/tmp}/woods-hook-input.XXXXXX")" || exit 0
14
+ trap 'rm -f "$input_file"' EXIT
15
+ trap 'exit 143' TERM INT
16
+ head -c 1048577 >"$input_file" || exit 0
17
+ payload_bytes="$(wc -c <"$input_file")"
18
+ [ "$payload_bytes" -le 1048576 ] || { printf '[Woods hooks] Oversized edit event; no refresh queued.\n' >&2; exit 0; }
19
+ if command -v jq >/dev/null 2>&1; then
20
+ payload="$(jq -c --arg client "$client" -f "$hook_dir/adapters/normalize.jq" <"$input_file" 2>/dev/null)" || {
21
+ printf '[Woods hooks] Unsupported or malformed edit event; no refresh queued.\n' >&2; exit 0;
22
+ }
23
+ elif command -v ruby >/dev/null 2>&1; then
24
+ payload="$(ruby "$hook_dir/adapters/normalize.rb" "$client" <"$input_file")" || exit 0
25
+ else
26
+ printf '[Woods hooks] jq or Ruby is required; no refresh queued.\n' >&2
27
+ exit 0
28
+ fi
29
+ rm -f "$input_file"
30
+ trap - EXIT TERM INT
31
+ field() {
32
+ if command -v jq >/dev/null 2>&1; then
33
+ printf '%s' "$payload" | jq -ej '.root'
34
+ else
35
+ printf '%s' "$payload" | ruby -rjson -e 'print JSON.parse($stdin.read).fetch("root")'
36
+ fi
37
+ }
38
+ cwd="$(field; printf .)"
39
+ cwd="${cwd%.}"
40
+ cwd="${cwd%/}"
41
+ [ -n "$cwd" ] || exit 0
42
+ [ -d "$cwd" ] || exit 0
43
+ configured_output="${WOODS_OUTPUT:-tmp/woods}"
44
+ case "$configured_output" in /*) tmp_dir="$configured_output" ;; *) tmp_dir="$cwd/$configured_output" ;; esac
45
+ [ -f "$tmp_dir/generation.json" ] || exit 0
46
+ source "$hook_dir/woods-input-rules.sh" || exit 0
47
+ # Validate every member before publishing the event. Missing/deleted paths are
48
+ # allowed; symlink components are deliberately unsupported, including escapes.
49
+ event_fields() {
50
+ if command -v jq >/dev/null 2>&1; then
51
+ printf '%s' "$payload" | jq -j '.events[] | .operation,"\u0000",.path,"\u0000"'
52
+ else
53
+ printf '%s' "$payload" | ruby -rjson -e 'JSON.parse($stdin.read).fetch("events").each { |e| print e.fetch("operation"), "\0", e.fetch("path"), "\0" }'
54
+ fi
55
+ }
56
+ relevant=0
57
+ invalid=0
58
+ while IFS= read -r -d '' operation && IFS= read -r -d '' file; do
59
+ case "$file" in "$cwd"/*) rel="${file#"$cwd"/}" ;; /*) invalid=1; break ;; *) rel="$file" ;; esac
60
+ case "/$rel/" in */../*|*/./*|*//* ) invalid=1; break ;; esac
61
+ parent="$cwd/$rel"
62
+ while [ "$parent" != "$cwd" ]; do
63
+ if [ -L "$parent" ]; then invalid=1; break; fi
64
+ parent="${parent%/*}"
65
+ done
66
+ [ "$invalid" = 0 ] || break
67
+ [ "$(woods_input_action "$rel")" = ignore ] || relevant=1
68
+ done < <(event_fields)
69
+ if [ "$invalid" = 1 ]; then
70
+ printf '[Woods hooks] Foreign, escaping or symlinked edit path; no refresh queued.\n' >&2
71
+ exit 0
72
+ fi
73
+ [ "$relevant" = 1 ] || exit 0
74
+
75
+ log="$tmp_dir/hook.log"
76
+ queue="$tmp_dir/hook-pending"
77
+ run_lock="$tmp_dir/hook.lock"
78
+ run_lock_dir="$tmp_dir/hook.lock.d"
79
+ mkdir -p "$queue" || exit 0
80
+ # Files are immutable once published. A killed worker never destroys its batch.
81
+ enqueue() {
82
+ event="$queue/$$.$RANDOM.$RANDOM"
83
+ if command -v jq >/dev/null 2>&1; then
84
+ jq -nc --arg path "$1" '{path:$path,operation:"update"}' >"$event.tmp" || return 1
85
+ else
86
+ ruby -rjson -e 'puts JSON.generate(path: ARGV[0], operation: "update")' -- "$1" >"$event.tmp" || return 1
87
+ fi
88
+ mv "$event.tmp" "$event.json"
89
+ }
90
+ # One immutable file keeps a multi-file event indivisible across crashes.
91
+ event="$queue/$$.$RANDOM.$RANDOM"
92
+ if command -v jq >/dev/null 2>&1; then
93
+ printf '%s' "$payload" | jq -c --arg root "$cwd" '.events | map(.path |= (if startswith($root + "/") then ltrimstr($root + "/") else . end))' >"$event.tmp" || exit 0
94
+ else
95
+ printf '%s' "$payload" | ruby -rjson -e 'v = JSON.parse($stdin.read); puts JSON.generate(v.fetch("events").map { |e| e.merge("path" => e.fetch("path").delete_prefix(ARGV.fetch(0) + "/")) })' -- "$cwd" >"$event.tmp" || exit 0
96
+ fi
97
+ mv "$event.tmp" "$event.json" || exit 0
98
+
99
+ # A private deadline applies even when the client backgrounds async hooks.
100
+ budget="${WOODS_HOOK_TIMEOUT_SECONDS:-600}"
101
+ case "$budget" in ''|*[!0-9]*|0) printf 'Invalid WOODS_HOOK_TIMEOUT_SECONDS; queued work retained.\n' >>"$log"; exit 0 ;; esac
102
+ [ "$budget" -le 3600 ] || exit 0
103
+ started=$SECONDS
104
+ have_flock() { command -v flock >/dev/null 2>&1; }
105
+ acquire() {
106
+ if have_flock; then
107
+ exec 8>"$run_lock"
108
+ flock -n 8
109
+ else
110
+ if ! mkdir "$run_lock_dir" 2>/dev/null; then
111
+ owners=("$run_lock_dir"/owner-*)
112
+ [ "${#owners[@]}" -le 1 ] || return 1
113
+ if [ "${#owners[@]}" -eq 1 ]; then
114
+ owner="${owners[0]##*/owner-}"
115
+ # Never steal a live owner's lease solely because its mtime is old.
116
+ case "$owner" in *[!0-9]*) return 1 ;; esac
117
+ kill -0 "$owner" 2>/dev/null && return 1
118
+ # Only the reclaimer that removes this marker may replace the directory.
119
+ # Another reclaimer may already have created a new, still-empty lock.
120
+ rm "${owners[0]}" 2>/dev/null || return 1
121
+ rmdir "$run_lock_dir" 2>/dev/null || return 1
122
+ else
123
+ # Compatibility recovery for an old empty mkdir lock after a crash.
124
+ mtime="$(stat -c %Y "$run_lock_dir" 2>/dev/null || stat -f %m "$run_lock_dir" 2>/dev/null)"
125
+ [ -n "$mtime" ] || return 1
126
+ now="$(date +%s)"
127
+ [ "$((now - mtime))" -gt "${WOODS_HOOK_LOCK_STALE_SECONDS:-1800}" ] || return 1
128
+ rmdir "$run_lock_dir" 2>/dev/null || return 1
129
+ fi
130
+ mkdir "$run_lock_dir" 2>/dev/null || return 1
131
+ fi
132
+ : >"$run_lock_dir/owner-$$" 2>/dev/null || return 1
133
+ owners=("$run_lock_dir"/owner-*)
134
+ if [ "${#owners[@]}" -ne 1 ]; then
135
+ rm -f "$run_lock_dir/owner-$$"
136
+ return 1
137
+ fi
138
+ fi
139
+ }
140
+ release() {
141
+ if have_flock; then flock -u 8; exec 8>&-; else rm -f "$run_lock_dir/owner-$$"; rmdir "$run_lock_dir" 2>/dev/null || true; fi
142
+ }
143
+ encode_batch() {
144
+ if command -v jq >/dev/null 2>&1; then
145
+ jq -sc --arg output "$configured_output" '{version:1,output:$output,events:(map(if type == "array" then . else [.] end) | add)} | @base64' "${batch[@]}" | tr -d '"\n'
146
+ else
147
+ ruby -rjson -rbase64 -e '
148
+ output = ARGV.shift
149
+ print Base64.strict_encode64(JSON.generate(version: 1, output: output,
150
+ events: ARGV.flat_map { |path| value = JSON.parse(File.read(path)); value.is_a?(Array) ? value : [value] }))
151
+ ' -- "$configured_output" "${batch[@]}"
152
+ fi
153
+ }
154
+ run_batch() {
155
+ encoded="$(encode_batch)" || return 1
156
+ [ -n "$encoded" ] || return 1
157
+ remaining=$((budget - SECONDS + started))
158
+ [ "$remaining" -gt 0 ] || return 124
159
+ rake="${WOODS_HOOK_RAKE:-bundle exec rake}"
160
+ # Job control gives this command and its descendants a dedicated process group.
161
+ # The task argument crosses Docker exec without requiring env forwarding or a
162
+ # host-only queue path to exist inside the application container.
163
+ set -m
164
+ ( exec 8>&-; cd "$cwd" || exit 1; exec $rake "woods:hook_refresh[$encoded]" ) >>"$log" 2>&1 &
165
+ runner=$!
166
+ ( exec 8>&-; sleep "$remaining"; kill -TERM -- "-$runner" 2>/dev/null; sleep 1; kill -KILL -- "-$runner" 2>/dev/null ) >/dev/null 2>&1 &
167
+ timer=$!
168
+ wait "$runner" 2>/dev/null
169
+ result=$?
170
+ kill -KILL -- "-$runner" 2>/dev/null || true
171
+ runner=""
172
+ kill -TERM -- "-$timer" 2>/dev/null || true
173
+ wait "$timer" 2>/dev/null || true
174
+ timer=""
175
+ set +m
176
+ return "$result"
177
+ }
178
+ cleanup() {
179
+ [ -z "${runner:-}" ] || kill -KILL -- "-$runner" 2>/dev/null || true
180
+ [ -z "${timer:-}" ] || kill -TERM -- "-$timer" 2>/dev/null || true
181
+ if [ "${legacy_directory_owned:-0}" = 1 ]; then
182
+ rmdir "$tmp_dir/hook-pending.lock.d" 2>/dev/null || true
183
+ fi
184
+ release
185
+ }
186
+ shopt -s nullglob
187
+ acquire || exit 0
188
+ trap 'exit 143' TERM INT
189
+ trap 'cleanup' EXIT
190
+ # Upgrade recovery: import the previous newline queue before retiring it.
191
+ # A crash during import can duplicate an event but cannot erase its obligation.
192
+ legacy_pending="$tmp_dir/hook-pending.txt"
193
+ if [ -s "$legacy_pending" ]; then
194
+ if have_flock; then
195
+ exec 7>"$tmp_dir/hook-pending.lock"
196
+ flock -n 7 || exit 0
197
+ else
198
+ if ! mkdir "$tmp_dir/hook-pending.lock.d" 2>/dev/null; then
199
+ printf 'Legacy queue import deferred: hook-pending.lock.d is busy; both queues retained.\n' >>"$log"
200
+ exit 0
201
+ fi
202
+ legacy_directory_owned=1
203
+ fi
204
+ while IFS= read -r legacy_path || [ -n "$legacy_path" ]; do
205
+ [ "$((SECONDS - started))" -lt "$budget" ] || exit 0
206
+ [ -n "$legacy_path" ] || continue
207
+ enqueue "$legacy_path" || exit 0
208
+ done <"$legacy_pending"
209
+ rm -f "$legacy_pending"
210
+ if have_flock; then
211
+ flock -u 7
212
+ exec 7>&-
213
+ else
214
+ rmdir "$tmp_dir/hook-pending.lock.d" 2>/dev/null || true
215
+ legacy_directory_owned=0
216
+ fi
217
+ fi
218
+ while :; do
219
+ batch=("$queue"/*.json)
220
+ if [ "${#batch[@]}" -eq 0 ]; then
221
+ # Release before checking again: an arrival either becomes our next batch
222
+ # or acquires ownership itself. There is no last-drain/release gap.
223
+ release
224
+ trap - EXIT
225
+ batch=("$queue"/*.json)
226
+ [ "${#batch[@]}" -gt 0 ] || break
227
+ acquire || break
228
+ trap 'cleanup' EXIT
229
+ continue
230
+ fi
231
+ candidates=("${batch[@]:0:16}")
232
+ batch=()
233
+ batch_bytes=0
234
+ batch_events=0
235
+ for candidate in "${candidates[@]}"; do
236
+ event_bytes="$(wc -c <"$candidate")"
237
+ if command -v jq >/dev/null 2>&1; then
238
+ event_count="$(jq 'if type == "array" then length else 1 end' "$candidate")" || break
239
+ else
240
+ event_count="$(ruby -rjson -e 'v = JSON.parse(File.read(ARGV[0])); puts(v.is_a?(Array) ? v.size : 1)' "$candidate")" || break
241
+ fi
242
+ [ "$((batch_events + event_count))" -le 1000 ] || break
243
+ [ "$((batch_bytes + event_bytes))" -le 49152 ] || break
244
+ batch+=("$candidate")
245
+ batch_bytes=$((batch_bytes + event_bytes))
246
+ batch_events=$((batch_events + event_count))
247
+ done
248
+ if [ "${#batch[@]}" -eq 0 ]; then
249
+ printf 'Oversized hook event; pending work retained for inspection.\n' >>"$log"
250
+ break
251
+ fi
252
+ if run_batch; then
253
+ rm -f "${batch[@]}"
254
+ else
255
+ result=$?
256
+ printf 'Woods hook deferred/failed (status %s); pending events retained in %s. Retry on the next edit or invoke the hook again after resolving the cause.\n' "$result" "$queue" >>"$log"
257
+ break
258
+ fi
259
+ done
260
+ exit 0
@@ -1,77 +1,69 @@
1
1
  #!/usr/bin/env bash
2
- # Woods SessionStart hook (#280): say so when the published generation is
3
- # older than the last commit. Stdout from a SessionStart hook is added to
4
- # the session context, so the agent sees the warning before it trusts the
5
- # index.
6
- #
7
- # This check only compares two commit-adjacent timestamps: the generation's
8
- # `updated_at` and `git log -1`'s commit time. It says nothing about
9
- # uncommitted edits (the index can be stale against a dirty working tree
10
- # with no stale commit to detect) or about a checkout sitting on an older
11
- # commit than the one that produced the generation (the timestamp comparison
12
- # can read as fresh there even though the code and the index disagree). Read
13
- # a quiet run as "not behind the last commit," not as "definitely current."
14
- #
15
- # Opt-in: shipped disabled. Nothing prints until WOODS_HOOKS_ENABLED=1 is
16
- # set; WOODS_HOOKS_DISABLED=1 turns it back off without touching that
17
- # setting.
2
+ # Opt-in, bounded source-content verification through the application's rake
3
+ # command. The status task has no Rails environment prerequisite; Docker-only
4
+ # installations do not need the Woods gem or an application bundle on the host.
18
5
  set -u
19
6
 
20
7
  [ "${WOODS_HOOKS_DISABLED:-0}" = "1" ] && exit 0
21
8
  [ "${WOODS_HOOKS_ENABLED:-0}" = "1" ] || exit 0
22
-
23
9
  payload="$(cat)"
24
-
25
10
  field() {
26
11
  if command -v jq >/dev/null 2>&1; then
27
- printf '%s' "$payload" | jq -r ".$1 // empty"
12
+ printf '%s' "$1" | jq -r --arg name "$2" '.[$name] // empty' 2>/dev/null
28
13
  elif command -v ruby >/dev/null 2>&1; then
29
- printf '%s' "$payload" | ruby -rjson -e '
30
- value = JSON.parse($stdin.read)
31
- ARGV[0].split(".").each { |key| value = value.is_a?(Hash) ? value[key] : nil }
32
- print value.to_s' -- "$1"
33
- else
34
- printf ''
14
+ printf '%s' "$1" | ruby -rjson -e 'print JSON.parse($stdin.read).fetch(ARGV[0], "")' -- "$2" 2>/dev/null
35
15
  fi
36
16
  }
37
-
38
- cwd="$(field cwd)"
39
- [ -z "$cwd" ] && exit 0
40
-
41
- # Same no-boot resolution the PostToolUse hook and woods:watch_status use:
42
- # WOODS_OUTPUT overrides outright, otherwise tmp/woods under cwd.
17
+ cwd="$(field "$payload" cwd)"
18
+ [ -n "$cwd" ] && [ -d "$cwd" ] || exit 0
43
19
  configured_output="${WOODS_OUTPUT:-tmp/woods}"
44
20
  case "$configured_output" in
45
- /*) tmp_dir="$configured_output" ;;
46
- *) tmp_dir="$cwd/$configured_output" ;;
21
+ /*) output_dir="$configured_output" ;;
22
+ *) output_dir="$cwd/$configured_output" ;;
47
23
  esac
48
-
49
- marker="$tmp_dir/generation.json"
50
- [ -f "$marker" ] || exit 0
51
-
24
+ [ -f "$output_dir/generation.json" ] || exit 0
52
25
  if command -v jq >/dev/null 2>&1; then
53
- updated="$(jq -r '.updated_at // empty' "$marker")"
54
- number="$(jq -r '.number // empty' "$marker")"
26
+ encoded="$(jq -nr --arg output "$configured_output" '{output:$output,mode:"quick"} | @base64')"
55
27
  elif command -v ruby >/dev/null 2>&1; then
56
- updated="$(ruby -rjson -e 'print JSON.parse(File.read(ARGV[0]))["updated_at"].to_s' -- "$marker")"
57
- number="$(ruby -rjson -e 'print JSON.parse(File.read(ARGV[0]))["number"].to_s' -- "$marker")"
28
+ encoded="$(ruby -rjson -rbase64 -e 'print Base64.strict_encode64(JSON.generate(output: ARGV[0], mode: "quick"))' -- "$configured_output")"
58
29
  else
59
30
  exit 0
60
31
  fi
61
- [ -z "$updated" ] && exit 0
62
32
 
63
- last_commit="$(git -C "$cwd" log -1 --format=%cI 2>/dev/null)"
64
- [ -z "$last_commit" ] && exit 0
65
-
66
- stale=0
67
- if command -v ruby >/dev/null 2>&1; then
68
- ruby -rtime -e 'exit(Time.parse(ARGV[0]) < Time.parse(ARGV[1]) ? 1 : 0)' -- "$updated" "$last_commit" || stale=1
69
- elif date -d "$updated" +%s >/dev/null 2>&1; then
70
- [ "$(date -d "$updated" +%s)" -lt "$(date -d "$last_commit" +%s)" ] && stale=1
71
- fi
72
-
73
- if [ "$stale" = "1" ]; then
74
- echo "Woods index is stale: generation ${number:-?} was published at $updated, before the last commit at $last_commit." \
75
- "Run bin/rails woods:incremental (or start bin/rails woods:watch) before trusting woods answers."
33
+ record="$(mktemp "${TMPDIR:-/tmp}/woods-source-status.XXXXXX")" || exit 0
34
+ runner=""; timer=""
35
+ cleanup() {
36
+ [ -z "$runner" ] || kill -KILL -- "-$runner" 2>/dev/null || true
37
+ [ -z "$timer" ] || kill -TERM -- "-$timer" 2>/dev/null || true
38
+ rm -f "$record"
39
+ }
40
+ trap cleanup EXIT
41
+ trap 'exit 0' INT TERM
42
+ # Ten seconds includes command startup; the shared content verifier itself has
43
+ # a 250ms scan budget. A client hook timeout alone is not a process deadline.
44
+ set -m
45
+ rake="${WOODS_HOOK_RAKE:-bundle exec rake}"
46
+ ( cd "$cwd" || exit 1; exec $rake "woods:source_status[$encoded]" ) >"$record" 2>/dev/null &
47
+ runner=$!
48
+ ( sleep 10; kill -TERM -- "-$runner" 2>/dev/null; sleep 1; kill -KILL -- "-$runner" 2>/dev/null ) >/dev/null 2>&1 &
49
+ timer=$!
50
+ wait "$runner" 2>/dev/null
51
+ result=$?
52
+ kill -KILL -- "-$runner" 2>/dev/null || true
53
+ runner=""
54
+ kill -TERM -- "-$timer" 2>/dev/null || true
55
+ wait "$timer" 2>/dev/null || true
56
+ timer=""
57
+ set +m
58
+ if [ "$result" -ne 0 ]; then
59
+ echo 'Woods source freshness is unknown: source verification was unavailable or exceeded its deadline. Inspect woods_status; use woods-extract full for a fresh capture.'
60
+ exit 0
76
61
  fi
62
+ status="$(tail -n 1 "$record")"
63
+ state="$(field "$status" state)"
64
+ case "$state" in
65
+ current) ;;
66
+ drifted) echo 'Woods source freshness is drifted: indexed application inputs differ from the working tree. Run woods-extract full, or inspect woods_status before choosing a targeted refresh.' ;;
67
+ *) echo 'Woods source freshness is unknown: this generation lacks complete verified source evidence. Inspect woods_status; use woods-extract full for a fresh capture.' ;;
68
+ esac
77
69
  exit 0
@@ -7,6 +7,19 @@ description: Make a repository's coding agents use the Woods index by default
7
7
 
8
8
  Installing the gem gives one operator an index; this skill gives every future agent session one. Wiring is three small, reviewable changes to the host repository. Each edits shared, checked-in files, so propose the diff and get approval before writing, match the repository's existing conventions, and leave unrelated content untouched.
9
9
 
10
+ ## Managed configuration availability
11
+
12
+ `woods-agent-config` (#407) is unreleased after `2.0.0.beta2`. First record the
13
+ installed version and test `bundle exec woods-agent-config --help` in the
14
+ selected application bundle. When supported, use its saved setup/update/remove
15
+ plan and explicit client/scope/root selection; apply the reviewed plan within
16
+ the user's existing authorization. Do not infer ownership from a server name
17
+ or repair edited managed sections by overwriting them. Plans and recovery
18
+ journals contain private configuration bytes. See the canonical
19
+ [managed configuration runbook](https://github.com/lost-in-the/woods/blob/main/docs/AGENT_SETUP.md#managed-claude-code-configuration)
20
+ for host/Compose preflight, actual Claude file locations, conflict recovery,
21
+ and removal. Preserve manual setup for older installed versions.
22
+
10
23
  ## Preflight
11
24
 
12
25
  Confirm Woods actually works before advertising it to every future session: