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,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,25 @@ 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 available in Woods `2.0.0.beta3`. 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
+
23
+ For concurrent configuration operations, wait for the active operation to
24
+ finish and generate a fresh plan if the saved snapshots changed. Do not remove
25
+ its lock or overwrite the other application's entry. Coordination across
26
+ applications sharing user configuration is unreleased after `2.0.0.beta3`;
27
+ check the installed revision before relying on it.
28
+
10
29
  ## Preflight
11
30
 
12
31
  Confirm Woods actually works before advertising it to every future session: