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,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'fileutils'
5
+ require_relative '../atomic_file'
6
+ require_relative 'plan'
7
+ require_relative 'recovery'
8
+
9
+ module Woods
10
+ module AgentConfiguration
11
+ # Atomic per-file replacement with a private recovery journal. Multiple
12
+ # configuration files cannot be one filesystem transaction; interrupted
13
+ # writes retain enough original bytes to explicitly recover without
14
+ # overwriting concurrent edits.
15
+ class Applier
16
+ include Recovery
17
+
18
+ def initialize(layout:, writer: Woods::AtomicFile.method(:write))
19
+ @layout = layout
20
+ @writer = writer
21
+ end
22
+
23
+ def apply(plan)
24
+ plan.validate!(@layout)
25
+ with_lock do
26
+ refuse_pending!
27
+ changes = plan.data.fetch('changes')
28
+ return 'already_applied' if changes.all? { |change| current(change) == Plan.after_fingerprint(change) }
29
+
30
+ journal = { 'schema_version' => 1, 'plan' => plan.data, 'originals' => originals(changes) }
31
+ write_journal(journal)
32
+ apply_changes(changes, journal)
33
+ 'applied'
34
+ end
35
+ end
36
+
37
+ def recover
38
+ with_lock do
39
+ document = Document.new(journal_path)
40
+ return 'nothing_to_recover' if document.content.nil?
41
+
42
+ recover_journal(document.json)
43
+ 'recovered'
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def journal_path
50
+ "#{@layout.receipt_path}.pending"
51
+ end
52
+
53
+ def with_lock(paths = @layout.lock_paths, &operation)
54
+ return operation.call if paths.empty?
55
+
56
+ path, *remaining = paths
57
+ Document.validate_path!(path)
58
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
59
+ File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600) do |lock|
60
+ raise Conflict, "Not a regular lock file: #{path}" unless lock.stat.file?
61
+ unless lock.flock(File::LOCK_EX | File::LOCK_NB)
62
+ raise Conflict,
63
+ 'Another Woods configuration operation is active; retry after it finishes'
64
+ end
65
+
66
+ with_lock(remaining, &operation)
67
+ ensure
68
+ lock&.flock(File::LOCK_UN)
69
+ end
70
+ rescue SystemCallError => e
71
+ raise Conflict, "Configuration write failed: #{e.message}"
72
+ end
73
+
74
+ def write_journal(journal)
75
+ encoded = JSON.generate(journal)
76
+ if encoded.bytesize > Document::MAX_BYTES
77
+ raise Conflict, 'Recovery journal would exceed 8 MiB; reduce the selected configuration before applying'
78
+ end
79
+
80
+ @writer.call(journal_path, encoded, mode: 0o600)
81
+ end
82
+
83
+ def refuse_pending!
84
+ return if Document.new(journal_path).content.nil?
85
+
86
+ raise Conflict, "An interrupted operation needs explicit recovery first: #{journal_path}"
87
+ end
88
+
89
+ def current(change)
90
+ Document.new(change.fetch('path')).fingerprint
91
+ end
92
+
93
+ def assert_snapshot!(actual, expected, path)
94
+ return if actual == expected
95
+
96
+ raise Conflict, "Changed since preview: #{path}; create a new plan without discarding the user's edits"
97
+ end
98
+
99
+ def replace(path, content, mode)
100
+ Document.validate_path!(path)
101
+ if content.nil?
102
+ remove_file(path)
103
+ else
104
+ @writer.call(path, content, mode: mode)
105
+ end
106
+ end
107
+
108
+ def remove_file(path)
109
+ File.unlink(path)
110
+ rescue Errno::ENOENT
111
+ nil
112
+ end
113
+
114
+ def originals(changes)
115
+ changes.map do |change|
116
+ document = Document.new(change.fetch('path'))
117
+ assert_snapshot!(document.fingerprint, change.fetch('before'), document.path)
118
+ { 'path' => document.path, 'content' => document.content && Base64.strict_encode64(document.content),
119
+ 'mode' => document.mode }
120
+ end
121
+ end
122
+
123
+ def apply_changes(changes, journal)
124
+ changes.each do |change|
125
+ assert_snapshot!(current(change), change.fetch('before'), change.fetch('path'))
126
+ replace(change.fetch('path'), Plan.after_content(change), change.fetch('mode'))
127
+ end
128
+ File.unlink(journal_path)
129
+ rescue StandardError => e
130
+ recover_journal(journal)
131
+ raise Conflict, "Apply failed and original files were restored: #{e.message}"
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cli_options'
4
+ require_relative 'plan_diff'
5
+ require_relative 'layout'
6
+ require_relative 'launcher'
7
+ require_relative 'preflight'
8
+ require_relative 'planner'
9
+ require_relative 'applier'
10
+
11
+ module Woods
12
+ module AgentConfiguration
13
+ class CLI
14
+ def initialize(stdout: $stdout, stderr: $stderr, preflight: Preflight.new)
15
+ @stdout = stdout
16
+ @stderr = stderr
17
+ @preflight = preflight
18
+ end
19
+
20
+ def run(argv)
21
+ options = { root: Dir.pwd, index: 'tmp/woods', mode: 'host', name: 'woods' }
22
+ command = argv.shift
23
+ parser = CLIOptions.build(options)
24
+ parser.parse!(argv)
25
+ if options[:help] || command.nil? || %w[-h --help].include?(command)
26
+ @stdout.puts(parser)
27
+ return 0
28
+ end
29
+ layout = Layout.new(root: options.fetch(:root), scope: options[:scope], client: options[:client])
30
+ result = dispatch(command, argv, options, layout)
31
+ @stdout.puts(JSON.pretty_generate(result))
32
+ 0
33
+ rescue Conflict, OptionParser::ParseError, SystemCallError => e
34
+ @stderr.puts("woods-agent-config: #{e.message}")
35
+ 1
36
+ end
37
+
38
+ private
39
+
40
+ def dispatch(command, argv, options, layout)
41
+ case command
42
+ when 'setup', 'update', 'remove'
43
+ raise Conflict, "Unexpected arguments: #{argv.join(' ')}" unless argv.empty?
44
+
45
+ create_plan(command, options, layout)
46
+ when 'show', 'apply'
47
+ consume_plan(command, argv, options, layout)
48
+ when 'recover'
49
+ raise Conflict, 'Recover does not accept positional arguments' unless argv.empty?
50
+
51
+ { 'result' => Applier.new(layout: layout).recover }
52
+ else
53
+ raise Conflict, 'Select setup, update, remove, show, apply, or recover'
54
+ end
55
+ end
56
+
57
+ def consume_plan(command, argv, options, layout)
58
+ raise Conflict, 'Select exactly one saved plan file' unless argv.size == 1
59
+
60
+ plan = Plan.load(argv.first).validate!(layout)
61
+ return { 'result' => Applier.new(layout: layout).apply(plan) } if command == 'apply'
62
+
63
+ PlanDiff.show(plan, @stderr) if options[:diff]
64
+ plan.summary
65
+ end
66
+
67
+ def create_plan(operation, options, layout)
68
+ path = options[:plan]
69
+ raise Conflict, 'Choose --plan FILE for the private, reviewable write plan' unless path
70
+
71
+ launcher = if operation != 'remove'
72
+ Launcher.new(root: layout.root, **options.slice(:index, :mode, :service, :container_root))
73
+ end
74
+ evidence = launcher ? @preflight.call(launcher) : {}
75
+ plan = Planner.new(layout: layout, operation: operation, entry: launcher&.entry,
76
+ name: options.fetch(:name), instructions: options[:instructions],
77
+ evidence: evidence, intent: launcher ? launcher.intent : {}).call
78
+ write_plan(path, plan, layout)
79
+ PlanDiff.show(plan, @stderr) if options[:diff]
80
+ plan.summary.merge('plan_file' => File.expand_path(path),
81
+ 'runtime_files' => layout.runtime_paths)
82
+ end
83
+
84
+ def write_plan(path, plan, layout)
85
+ target = File.expand_path(path)
86
+ protected_paths = layout.allowed_paths + layout.runtime_paths
87
+ raise Conflict, 'Plan output must differ from every managed/runtime target' if protected_paths.include?(target)
88
+
89
+ Document.validate_path!(target)
90
+ content = "#{JSON.pretty_generate(plan.data)}\n"
91
+ raise Conflict, 'Plan exceeds the supported size' if content.bytesize > Document::MAX_BYTES
92
+
93
+ File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
94
+ file.write(content)
95
+ file.flush
96
+ file.fsync
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Woods
6
+ module AgentConfiguration
7
+ module CLIOptions
8
+ def self.build(options)
9
+ OptionParser.new do |parser|
10
+ parser.banner = <<~USAGE
11
+ Usage: woods-agent-config setup|update|remove --client claude --scope project|user --plan FILE [options]
12
+ woods-agent-config apply|show FILE --client claude --scope project|user [--root ROOT]
13
+ woods-agent-config recover --client claude --scope project|user [--root ROOT]
14
+ Setup/update/remove create a private plan without changing managed configuration.
15
+ Apply consumes that exact plan. Only Index MCP is configured; client trust remains separate.
16
+ USAGE
17
+ { client: '--client NAME', scope: '--scope SCOPE', root: '--root DIRECTORY',
18
+ plan: '--plan FILE', name: '--name NAME', index: '--index PATH', mode: '--mode MODE',
19
+ service: '--service NAME', container_root: '--container-root PATH' }.each do |key, option|
20
+ parser.on(option) { |value| options[key] = value }
21
+ end
22
+ parser.on('--instructions LIST', Array) { |value| options[:instructions] = value }
23
+ parser.on('--diff') { options[:diff] = true }
24
+ parser.on('-h', '--help') { options[:help] = true }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require_relative 'error'
6
+
7
+ module Woods
8
+ module AgentConfiguration
9
+ # Reads bounded regular files and rejects symlink components before writes.
10
+ # Preview/apply snapshots include exact bytes and permissions, so edits made
11
+ # after preview cannot be overwritten by a stale plan.
12
+ class Document
13
+ MAX_BYTES = 8 * 1024 * 1024
14
+
15
+ class UniqueObject < Hash
16
+ def []=(key, value)
17
+ raise Conflict, "Duplicate JSON key #{key.inspect}; resolve the ambiguous configuration" if key?(key)
18
+
19
+ super
20
+ end
21
+ end
22
+
23
+ attr_reader :path, :content, :mode
24
+
25
+ def initialize(path)
26
+ @path = File.expand_path(path)
27
+ self.class.validate_path!(@path)
28
+ @content = nil
29
+ @mode = 0o600
30
+ return unless File.exist?(@path)
31
+
32
+ File.open(@path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
33
+ stat = file.stat
34
+ raise Conflict, "Expected a regular file: #{@path}" unless stat.file?
35
+ raise Conflict, "Configuration file exceeds #{MAX_BYTES} bytes: #{@path}" if stat.size > MAX_BYTES
36
+
37
+ read_content(file)
38
+ @mode = stat.mode & 0o777
39
+ end
40
+ rescue SystemCallError => e
41
+ raise Conflict, "Cannot read configuration #{@path}: #{e.message}"
42
+ end
43
+
44
+ def self.validate_path!(path)
45
+ current = File.expand_path(path)
46
+ loop do
47
+ raise Conflict, "Symlink target or parent is unsupported: #{current}" if File.symlink?(current)
48
+
49
+ parent = File.dirname(current)
50
+ break if parent == current
51
+
52
+ current = parent
53
+ end
54
+ end
55
+
56
+ def fingerprint
57
+ { 'sha256' => content && Digest::SHA256.hexdigest(content), 'mode' => mode, 'exists' => !content.nil? }
58
+ end
59
+
60
+ def json
61
+ return {} if content.nil?
62
+
63
+ value = JSON.parse(content, object_class: UniqueObject)
64
+ raise Conflict, "Expected a JSON object: #{path}" unless value.is_a?(Hash)
65
+
66
+ plain_value(value)
67
+ rescue JSON::ParserError => e
68
+ raise Conflict, "Malformed JSON in #{path}: #{e.message}"
69
+ end
70
+
71
+ def encode_json(value)
72
+ return content if !content.nil? && json == value
73
+
74
+ return JSON.generate(value) if content && !content.include?("\n")
75
+
76
+ JSON.pretty_generate(value, indent: json_indent).gsub("\n", newline) + newline
77
+ end
78
+
79
+ private
80
+
81
+ def read_content(file)
82
+ @content = (file.read(MAX_BYTES + 1) || String.new).force_encoding(Encoding::UTF_8)
83
+ raise Conflict, "Invalid UTF-8 configuration: #{@path}" unless @content.valid_encoding?
84
+ raise Conflict, "Configuration file too large: #{@path}" if @content.bytesize > MAX_BYTES
85
+ end
86
+
87
+ def newline
88
+ content&.include?("\r\n") ? "\r\n" : "\n"
89
+ end
90
+
91
+ def json_indent
92
+ match = content&.match(/\n([ \t]+)"/)
93
+ match ? match[1] : ' '
94
+ end
95
+
96
+ def plain_value(value)
97
+ case value
98
+ when Hash then value.each_with_object({}) { |(key, item), hash| hash[key] = plain_value(item) }
99
+ when Array then value.map { |item| plain_value(item) }
100
+ else value
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module AgentConfiguration
5
+ class Conflict < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative 'error'
5
+
6
+ module Woods
7
+ module AgentConfiguration
8
+ # Supported command shapes are built as argv, never as shell snippets.
9
+ class Launcher
10
+ attr_reader :root, :index, :mode, :service, :container_root
11
+
12
+ def initialize(root:, index: 'tmp/woods', mode: 'host', service: nil, container_root: nil)
13
+ @root = File.realpath(root)
14
+ @index = Pathname.new(index).cleanpath.to_s
15
+ @mode = mode
16
+ @service = service
17
+ @container_root = container_root
18
+ unless !Pathname.new(@index).absolute? && @index != '..' && !@index.start_with?('../')
19
+ raise Conflict, 'Index must be an explicit path inside the selected application root'
20
+ end
21
+ raise Conflict, 'Application Gemfile is missing' unless File.file?(File.join(@root, 'Gemfile'))
22
+ raise Conflict, 'Supported launch modes: host, compose' unless %w[host compose].include?(mode)
23
+
24
+ validate_compose! if mode == 'compose'
25
+ end
26
+
27
+ def entry
28
+ argv = prefix + ['bundle', 'exec', 'woods-mcp-start', index_path]
29
+ { 'type' => 'stdio', 'command' => argv.first, 'args' => argv.drop(1), 'env' => environment }
30
+ end
31
+
32
+ def probe_command(script)
33
+ prefix(frozen: true) + ['bundle', 'exec', 'ruby', '-e', script, index_path, process_root]
34
+ end
35
+
36
+ def environment
37
+ mode == 'host' ? { 'BUNDLE_GEMFILE' => File.join(root, 'Gemfile') } : {}
38
+ end
39
+
40
+ def probe_environment
41
+ environment.merge('BUNDLE_FROZEN' => 'true')
42
+ end
43
+
44
+ def intent
45
+ { 'mode' => mode, 'index' => index, 'service' => service, 'container_root' => container_root }.compact
46
+ end
47
+
48
+ private
49
+
50
+ def process_root
51
+ mode == 'host' ? root : container_root
52
+ end
53
+
54
+ def index_path
55
+ File.join(process_root, index)
56
+ end
57
+
58
+ def prefix(frozen: false)
59
+ return [] if mode == 'host'
60
+
61
+ ['docker', 'compose', '--project-directory', root, 'exec', '-T', '-w', container_root] +
62
+ (frozen ? ['-e', 'BUNDLE_FROZEN=true'] : []) + [service]
63
+ end
64
+
65
+ def validate_compose!
66
+ unless service.is_a?(String) && service.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]*\z/)
67
+ raise Conflict, 'Compose mode requires an explicit service name'
68
+ end
69
+ return if container_root.is_a?(String) && Pathname.new(container_root).absolute?
70
+
71
+ raise Conflict, 'Compose mode requires the absolute application path visible inside the container'
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'pathname'
5
+ require_relative 'error'
6
+
7
+ module Woods
8
+ module AgentConfiguration
9
+ # Explicit client/scope selection; no discovery or fallback into a user home.
10
+ class Layout
11
+ attr_reader :root, :scope, :config_dir
12
+
13
+ def initialize(root:, scope:, client: 'claude', home: Dir.home, config_dir: ENV.fetch('CLAUDE_CONFIG_DIR', nil))
14
+ raise Conflict, 'Supported client: claude' unless client == 'claude'
15
+ raise Conflict, 'Select scope project or user explicitly' unless %w[project user].include?(scope)
16
+
17
+ @root = File.realpath(root)
18
+ raise Conflict, 'Application root must be a directory' unless File.directory?(@root)
19
+
20
+ @scope = scope
21
+ @home = File.expand_path(home)
22
+ @custom_config = config_dir && !config_dir.empty?
23
+ @config_dir = File.expand_path(@custom_config ? config_dir : File.join(@home, '.claude'))
24
+ rescue Errno::ENOENT => e
25
+ raise Conflict, "Application root unavailable: #{e.message}"
26
+ end
27
+
28
+ def config_path
29
+ return File.join(root, '.mcp.json') if scope == 'project'
30
+
31
+ File.join(@custom_config ? config_dir : @home, '.claude.json')
32
+ end
33
+
34
+ def receipt_path
35
+ return File.join(root, '.woods-agent-config.json') if scope == 'project'
36
+
37
+ File.join(config_dir, "woods-agent-#{Digest::SHA256.hexdigest(root)[0, 16]}.json")
38
+ end
39
+
40
+ def instruction_path(name)
41
+ names = scope == 'project' ? %w[CLAUDE.md AGENTS.md] : ['CLAUDE.md']
42
+ raise Conflict, "Supported #{scope} instruction files: #{names.join(', ')}" unless names.include?(name)
43
+
44
+ File.join(scope == 'project' ? root : config_dir, name)
45
+ end
46
+
47
+ def allowed_paths
48
+ [config_path, receipt_path, instruction_path('CLAUDE.md')].tap do |paths|
49
+ paths << instruction_path('AGENTS.md') if scope == 'project'
50
+ end
51
+ end
52
+
53
+ # Lock actual managed targets, since user-scoped paths can be shared
54
+ # by different applications. Keep the receipt lock name for existing
55
+ # same-application callers; receipts and recovery journals stay separate.
56
+ def lock_paths
57
+ allowed_paths.map do |path|
58
+ path == receipt_path ? "#{path}.lock" : "#{path}.woods.lock"
59
+ end.sort
60
+ end
61
+
62
+ def runtime_paths
63
+ lock_paths + ["#{receipt_path}.pending"]
64
+ end
65
+
66
+ def identity
67
+ { 'client' => 'claude', 'scope' => scope, 'root' => root, 'config_dir' => config_dir,
68
+ 'config_path' => config_path, 'receipt_path' => receipt_path }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error'
4
+
5
+ module Woods
6
+ module AgentConfiguration
7
+ # Only a receipt-owned, byte-identical section may be replaced or removed.
8
+ class ManagedSection
9
+ START = '<!-- woods:managed:start -->'
10
+ FINISH = '<!-- woods:managed:end -->'
11
+ BODY = <<~MARKDOWN
12
+ ## Codebase index (Woods)
13
+
14
+ Call `woods_status` before relying on the published index. For structural
15
+ questions use `search`, `lookup`, and `dependencies`/`dependents` as needed.
16
+ Check freshness and search completeness before treating missing evidence
17
+ as absent code. Read source and run relevant tests to verify changes.
18
+ The server's tool descriptions define its available capabilities.
19
+ MARKDOWN
20
+
21
+ def self.render(content)
22
+ newline = content&.include?("\r\n") ? "\r\n" : "\n"
23
+ "#{START}\n#{BODY}#{FINISH}\n".gsub("\n", newline)
24
+ end
25
+
26
+ def self.change(content, previous:, remove: false)
27
+ text = content || ''
28
+ starts = text.scan(START).size
29
+ finishes = text.scan(FINISH).size
30
+ return replace_owned(text, previous, starts, finishes, remove) if previous
31
+
32
+ unless starts.zero? && finishes.zero?
33
+ raise Conflict, 'Unowned or ambiguous Woods markers; resolve them before setup'
34
+ end
35
+ return [content, nil] if remove
36
+
37
+ prefix = separator(text)
38
+ block = prefix + render(text)
39
+ [text + block, { 'owned_text' => block, 'prefix' => prefix }]
40
+ end
41
+
42
+ def self.replace_owned(text, previous, starts, finishes, remove)
43
+ unless starts == 1 && finishes == 1 && text.include?(previous.fetch('owned_text'))
44
+ raise Conflict, 'Managed instruction section was edited or removed; restore it or resolve ownership manually'
45
+ end
46
+ return [text.sub(previous.fetch('owned_text'), ''), nil] if remove
47
+
48
+ block = previous.fetch('prefix') + render(text)
49
+ [text.sub(previous.fetch('owned_text'), block), { 'owned_text' => block, 'prefix' => previous.fetch('prefix') }]
50
+ end
51
+ private_class_method :replace_owned
52
+
53
+ def self.separator(text)
54
+ return '' if text.empty?
55
+
56
+ newline = text.include?("\r\n") ? "\r\n" : "\n"
57
+ text.end_with?(newline) ? newline : newline * 2
58
+ end
59
+ private_class_method :separator
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+ require_relative 'document'
6
+
7
+ module Woods
8
+ module AgentConfiguration
9
+ # The serialized plan is the write set. Apply checks its original snapshots
10
+ # and never independently recomputes a newer set of intended edits.
11
+ class Plan
12
+ SCHEMA_VERSION = 1
13
+
14
+ attr_reader :data
15
+
16
+ def initialize(layout:, operation:, evidence: {})
17
+ @data = { 'schema_version' => SCHEMA_VERSION, 'layout' => layout.identity,
18
+ 'operation' => operation, 'evidence' => evidence, 'changes' => [] }
19
+ end
20
+
21
+ def self.load(path)
22
+ object = allocate
23
+ object.instance_variable_set(:@data, Document.new(path).json)
24
+ object
25
+ end
26
+
27
+ def add(document, content, description:)
28
+ return if content == document.content
29
+
30
+ data.fetch('changes') << { 'path' => document.path, 'before' => document.fingerprint,
31
+ 'after' => content && Base64.strict_encode64(content),
32
+ 'mode' => document.mode, 'description' => description }
33
+ end
34
+
35
+ def validate!(layout)
36
+ validate_format!(layout)
37
+ validate_paths!(layout)
38
+ data.fetch('changes').each { |change| validate_change!(change) }
39
+ self
40
+ rescue KeyError, TypeError, ArgumentError => e
41
+ raise Conflict, "Invalid plan: #{e.message}"
42
+ end
43
+
44
+ def self.after_content(change)
45
+ encoded = change.fetch('after')
46
+ encoded && Base64.strict_decode64(encoded).force_encoding(Encoding::UTF_8)
47
+ end
48
+
49
+ def self.after_fingerprint(change)
50
+ content = after_content(change)
51
+ { 'sha256' => content && Digest::SHA256.hexdigest(content), 'mode' => content ? change.fetch('mode') : 0o600,
52
+ 'exists' => !content.nil? }
53
+ end
54
+
55
+ def summary
56
+ data.slice('operation', 'layout', 'evidence').merge(
57
+ 'changes' => data.fetch('changes').map do |change|
58
+ change.slice('path', 'description').merge('action' => change['after'] ? 'write' : 'remove')
59
+ end
60
+ )
61
+ end
62
+
63
+ def to_json(*arguments)
64
+ data.to_json(*arguments)
65
+ end
66
+
67
+ private
68
+
69
+ def validate_format!(layout)
70
+ valid = data.is_a?(Hash) && data['schema_version'] == SCHEMA_VERSION && data['layout'] == layout.identity &&
71
+ %w[setup update remove].include?(data['operation']) && data['changes'].is_a?(Array) &&
72
+ data['changes'].all?(Hash)
73
+ return if valid
74
+
75
+ raise Conflict, 'Plan format or selected application/client/scope differs; create a fresh preview'
76
+ end
77
+
78
+ def validate_paths!(layout)
79
+ paths = data.fetch('changes').map { |change| change.fetch('path') }
80
+ return if paths.uniq == paths && (paths - layout.allowed_paths).empty?
81
+
82
+ raise Conflict, 'Plan contains duplicate or out-of-scope targets'
83
+ end
84
+
85
+ def validate_change!(change)
86
+ unless change['before'].is_a?(Hash) && change['mode'].is_a?(Integer) && change['mode'].between?(0, 0o777)
87
+ raise Conflict, 'Invalid file snapshot in plan'
88
+ end
89
+
90
+ content = self.class.after_content(change)
91
+ return if content.nil?
92
+
93
+ raise Conflict, 'Planned file exceeds the supported size' if content.bytesize > Document::MAX_BYTES
94
+ raise Conflict, 'Planned file must contain valid UTF-8' unless content.valid_encoding?
95
+ end
96
+ end
97
+ end
98
+ end