textus 0.52.0 → 0.53.0

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 (254) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +62 -187
  5. data/docs/architecture/README.md +88 -77
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +53 -0
  8. data/lib/textus/action/audit.rb +133 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +30 -22
  11. data/lib/textus/action/boot.rb +26 -0
  12. data/lib/textus/action/data_mv.rb +71 -0
  13. data/lib/textus/action/deps.rb +48 -0
  14. data/lib/textus/action/doctor.rb +26 -0
  15. data/lib/textus/action/drain.rb +41 -0
  16. data/lib/textus/action/enqueue.rb +55 -0
  17. data/lib/textus/action/get.rb +80 -0
  18. data/lib/textus/action/jobs.rb +38 -0
  19. data/lib/textus/action/key_delete.rb +46 -0
  20. data/lib/textus/action/key_delete_prefix.rb +46 -0
  21. data/lib/textus/action/key_mv.rb +143 -0
  22. data/lib/textus/action/key_mv_prefix.rb +59 -0
  23. data/lib/textus/action/list.rb +44 -0
  24. data/lib/textus/action/propose.rb +54 -0
  25. data/lib/textus/action/published.rb +26 -0
  26. data/lib/textus/action/pulse/scanner.rb +118 -0
  27. data/lib/textus/action/pulse.rb +87 -0
  28. data/lib/textus/action/put.rb +63 -0
  29. data/lib/textus/action/rdeps.rb +49 -0
  30. data/lib/textus/action/reject.rb +49 -0
  31. data/lib/textus/action/rule_explain.rb +95 -0
  32. data/lib/textus/action/rule_lint.rb +70 -0
  33. data/lib/textus/action/rule_list.rb +46 -0
  34. data/lib/textus/action/schema_envelope.rb +31 -0
  35. data/lib/textus/action/uid.rb +35 -0
  36. data/lib/textus/action/where.rb +38 -0
  37. data/lib/textus/action/write_verb.rb +58 -0
  38. data/lib/textus/background/job/base.rb +27 -0
  39. data/lib/textus/background/job/materialize.rb +31 -0
  40. data/lib/textus/background/job/refresh.rb +22 -0
  41. data/lib/textus/background/job/sweep.rb +31 -0
  42. data/lib/textus/background/job.rb +19 -0
  43. data/lib/textus/background/plan.rb +9 -0
  44. data/lib/textus/background/planner/plan.rb +113 -0
  45. data/lib/textus/{maintenance → background}/retention/apply.rb +7 -9
  46. data/lib/textus/background/worker.rb +67 -0
  47. data/lib/textus/boot.rb +53 -45
  48. data/lib/textus/command.rb +36 -0
  49. data/lib/textus/container.rb +1 -1
  50. data/lib/textus/{domain → core}/duration.rb +1 -1
  51. data/lib/textus/{domain → core}/freshness/evaluator.rb +11 -11
  52. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  53. data/lib/textus/{domain → core}/freshness.rb +2 -2
  54. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  55. data/lib/textus/{domain → core}/retention.rb +2 -2
  56. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  57. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  58. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  59. data/lib/textus/doctor/check/hooks.rb +11 -18
  60. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  61. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  62. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  63. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  64. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  65. data/lib/textus/doctor/check.rb +12 -9
  66. data/lib/textus/{read → doctor}/validator.rb +22 -13
  67. data/lib/textus/doctor.rb +6 -6
  68. data/lib/textus/envelope/io/writer.rb +65 -36
  69. data/lib/textus/envelope.rb +5 -3
  70. data/lib/textus/errors.rb +17 -9
  71. data/lib/textus/events.rb +21 -0
  72. data/lib/textus/gate/auth.rb +181 -0
  73. data/lib/textus/gate.rb +114 -0
  74. data/lib/textus/init/templates/machine_intake.rb +39 -35
  75. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  76. data/lib/textus/init.rb +90 -73
  77. data/lib/textus/key/path.rb +9 -2
  78. data/lib/textus/layout.rb +13 -0
  79. data/lib/textus/manifest/data.rb +14 -14
  80. data/lib/textus/manifest/entry/base.rb +15 -11
  81. data/lib/textus/manifest/entry/parser.rb +6 -6
  82. data/lib/textus/manifest/entry/produced.rb +3 -2
  83. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  84. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  85. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  86. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  87. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  88. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  89. data/lib/textus/manifest/policy/react.rb +30 -0
  90. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  91. data/lib/textus/{domain → manifest}/policy/source.rb +24 -19
  92. data/lib/textus/manifest/policy.rb +36 -48
  93. data/lib/textus/manifest/resolver.rb +3 -2
  94. data/lib/textus/manifest/rules.rb +4 -4
  95. data/lib/textus/manifest/schema/keys.rb +17 -11
  96. data/lib/textus/manifest/schema/validator.rb +24 -22
  97. data/lib/textus/manifest/schema/vocabulary.rb +1 -1
  98. data/lib/textus/manifest/schema.rb +2 -2
  99. data/lib/textus/manifest.rb +2 -2
  100. data/lib/textus/{produce → pipeline}/acquire/handler.rb +2 -2
  101. data/lib/textus/{produce → pipeline}/acquire/intake.rb +22 -20
  102. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  103. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  104. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  105. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  106. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  107. data/lib/textus/{produce → pipeline}/engine.rb +7 -5
  108. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  109. data/lib/textus/ports/audit_log.rb +31 -5
  110. data/lib/textus/ports/audit_subscriber.rb +4 -4
  111. data/lib/textus/{domain/jobs → ports/queue}/job.rb +19 -12
  112. data/lib/textus/ports/queue.rb +1 -1
  113. data/lib/textus/ports/sentinel_store.rb +2 -2
  114. data/lib/textus/ports/watcher_lock.rb +48 -0
  115. data/lib/textus/projection.rb +8 -8
  116. data/lib/textus/schema/tools.rb +4 -3
  117. data/lib/textus/session.rb +6 -3
  118. data/lib/textus/step/base.rb +35 -0
  119. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  120. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  121. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  122. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  123. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  124. data/lib/textus/step/builtin.rb +22 -0
  125. data/lib/textus/{hooks → step}/catalog.rb +3 -3
  126. data/lib/textus/{hooks → step}/context.rb +15 -13
  127. data/lib/textus/step/discovery.rb +24 -0
  128. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  129. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  130. data/lib/textus/step/fetch.rb +13 -0
  131. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  132. data/lib/textus/step/loader.rb +108 -0
  133. data/lib/textus/step/observe.rb +31 -0
  134. data/lib/textus/step/registry_store.rb +66 -0
  135. data/lib/textus/{hooks → step}/signature.rb +1 -1
  136. data/lib/textus/step/transform.rb +12 -0
  137. data/lib/textus/step/validate.rb +11 -0
  138. data/lib/textus/step.rb +10 -0
  139. data/lib/textus/store.rb +17 -15
  140. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  141. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  142. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  143. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  144. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  145. data/lib/textus/surfaces/cli/group.rb +50 -0
  146. data/lib/textus/surfaces/cli/runner.rb +236 -0
  147. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  148. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  149. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  150. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  151. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  152. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  153. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  155. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  156. data/lib/textus/surfaces/cli/verb.rb +111 -0
  157. data/lib/textus/surfaces/cli.rb +148 -0
  158. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  159. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  160. data/lib/textus/surfaces/mcp/server.rb +145 -0
  161. data/lib/textus/surfaces/mcp/session.rb +9 -0
  162. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  163. data/lib/textus/surfaces/mcp.rb +8 -0
  164. data/lib/textus/surfaces/role_scope.rb +38 -0
  165. data/lib/textus/surfaces/watcher.rb +38 -0
  166. data/lib/textus/version.rb +1 -1
  167. data/lib/textus.rb +64 -22
  168. metadata +132 -118
  169. data/lib/textus/cli/group/hook.rb +0 -9
  170. data/lib/textus/cli/group/key.rb +0 -9
  171. data/lib/textus/cli/group/mcp.rb +0 -9
  172. data/lib/textus/cli/group/rule.rb +0 -9
  173. data/lib/textus/cli/group/schema.rb +0 -9
  174. data/lib/textus/cli/group/zone.rb +0 -9
  175. data/lib/textus/cli/group.rb +0 -48
  176. data/lib/textus/cli/runner.rb +0 -193
  177. data/lib/textus/cli/verb/doctor.rb +0 -17
  178. data/lib/textus/cli/verb/get.rb +0 -18
  179. data/lib/textus/cli/verb/hook_run.rb +0 -48
  180. data/lib/textus/cli/verb/hooks.rb +0 -50
  181. data/lib/textus/cli/verb/init.rb +0 -18
  182. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  183. data/lib/textus/cli/verb/put.rb +0 -30
  184. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  185. data/lib/textus/cli/verb/schema_init.rb +0 -19
  186. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  187. data/lib/textus/cli/verb/serve.rb +0 -19
  188. data/lib/textus/cli/verb.rb +0 -116
  189. data/lib/textus/cli.rb +0 -138
  190. data/lib/textus/dispatcher.rb +0 -54
  191. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  192. data/lib/textus/domain/action.rb +0 -9
  193. data/lib/textus/domain/jobs/registry.rb +0 -37
  194. data/lib/textus/domain/permission.rb +0 -7
  195. data/lib/textus/domain/policy/base_guards.rb +0 -25
  196. data/lib/textus/domain/policy/evaluation.rb +0 -15
  197. data/lib/textus/domain/policy/guard.rb +0 -35
  198. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  199. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  200. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  201. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  202. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  203. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  204. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  205. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  206. data/lib/textus/hooks/builtin.rb +0 -70
  207. data/lib/textus/hooks/loader.rb +0 -54
  208. data/lib/textus/hooks/rpc_registry.rb +0 -43
  209. data/lib/textus/jobs/handlers.rb +0 -62
  210. data/lib/textus/jobs/scheduler.rb +0 -36
  211. data/lib/textus/jobs/seeder.rb +0 -57
  212. data/lib/textus/maintenance/drain.rb +0 -42
  213. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  214. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/serve.rb +0 -30
  217. data/lib/textus/maintenance/worker.rb +0 -74
  218. data/lib/textus/maintenance/zone_mv.rb +0 -64
  219. data/lib/textus/maintenance.rb +0 -15
  220. data/lib/textus/mcp/catalog.rb +0 -70
  221. data/lib/textus/mcp/errors.rb +0 -32
  222. data/lib/textus/mcp/server.rb +0 -138
  223. data/lib/textus/mcp/session.rb +0 -7
  224. data/lib/textus/mcp/tool_schemas.rb +0 -15
  225. data/lib/textus/mcp.rb +0 -6
  226. data/lib/textus/mustache.rb +0 -117
  227. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  228. data/lib/textus/produce/events.rb +0 -36
  229. data/lib/textus/read/audit.rb +0 -130
  230. data/lib/textus/read/boot.rb +0 -26
  231. data/lib/textus/read/capabilities.rb +0 -70
  232. data/lib/textus/read/deps.rb +0 -38
  233. data/lib/textus/read/doctor.rb +0 -27
  234. data/lib/textus/read/freshness.rb +0 -152
  235. data/lib/textus/read/get.rb +0 -73
  236. data/lib/textus/read/jobs.rb +0 -31
  237. data/lib/textus/read/list.rb +0 -24
  238. data/lib/textus/read/published.rb +0 -22
  239. data/lib/textus/read/pulse.rb +0 -98
  240. data/lib/textus/read/rdeps.rb +0 -39
  241. data/lib/textus/read/rule_explain.rb +0 -96
  242. data/lib/textus/read/rule_list.rb +0 -54
  243. data/lib/textus/read/schema_envelope.rb +0 -25
  244. data/lib/textus/read/uid.rb +0 -29
  245. data/lib/textus/read/validate_all.rb +0 -36
  246. data/lib/textus/read/where.rb +0 -24
  247. data/lib/textus/role_scope.rb +0 -78
  248. data/lib/textus/write/accept.rb +0 -58
  249. data/lib/textus/write/enqueue.rb +0 -50
  250. data/lib/textus/write/key_delete.rb +0 -65
  251. data/lib/textus/write/key_mv.rb +0 -141
  252. data/lib/textus/write/propose.rb +0 -54
  253. data/lib/textus/write/put.rb +0 -74
  254. data/lib/textus/write/reject.rb +0 -68
@@ -1,74 +0,0 @@
1
- module Textus
2
- module Maintenance
3
- # Drains the job queue: lease a job, look up its handler in the registry, run
4
- # it (as the job's stamped authority — wired in a later phase), then ack on
5
- # success or fail (requeue/dead-letter) on a raise. `drain` runs until the
6
- # queue is empty and returns a summary. Delivery is at-least-once.
7
- class Worker
8
- Summary = Struct.new(:completed, :failed, keyword_init: true)
9
-
10
- # The standard convergence worker: the closed handler allow-list plus the
11
- # lease TTL from worker_config. Both `drain` and `serve` build it this way.
12
- def self.for(container:, queue:)
13
- new(
14
- queue: queue, registry: Textus::Jobs::Handlers.registry,
15
- container: container, lease_ttl: container.manifest.data.worker_config[:lease_ttl]
16
- )
17
- end
18
-
19
- def initialize(queue:, registry:, container:, lease_ttl: 60)
20
- @queue = queue
21
- @registry = registry
22
- @container = container
23
- @lease_ttl = lease_ttl
24
- end
25
-
26
- def drain(worker_id: "drain-#{Process.pid}")
27
- completed = 0
28
- failed = 0
29
- loop do
30
- leased = @queue.lease(worker_id: worker_id, lease_ttl: @lease_ttl)
31
- break unless leased
32
-
33
- case run_one(leased)
34
- when :completed then completed += 1
35
- when :dead_lettered then failed += 1
36
- # :requeued -> a transient failure; it re-leases on a later iteration
37
- end
38
- end
39
- Summary.new(completed: completed, failed: failed)
40
- end
41
-
42
- def drain_pool(pool: 4)
43
- summaries = []
44
- mutex = Mutex.new
45
- threads = Array.new(pool) do |i|
46
- Thread.new do
47
- s = drain(worker_id: "pool-#{Process.pid}-#{i}")
48
- mutex.synchronize { summaries << s }
49
- end
50
- end
51
- threads.each(&:join)
52
- Summary.new(
53
- completed: summaries.sum(&:completed),
54
- failed: summaries.sum(&:failed),
55
- )
56
- end
57
-
58
- private
59
-
60
- # Returns :completed on ack, or the queue's failure verdict (:requeued |
61
- # :dead_lettered) on a raise. A requeued job re-leases on the next loop
62
- # iteration, so a transient failure still drains; only a dead-letter is a
63
- # terminal failure that counts toward the summary.
64
- def run_one(leased)
65
- entry = @registry.lookup(leased.job.type)
66
- entry.handler.call(job: leased.job, container: @container)
67
- @queue.ack(leased)
68
- :completed
69
- rescue StandardError => e
70
- @queue.fail(leased, error: e.message)
71
- end
72
- end
73
- end
74
- end
@@ -1,64 +0,0 @@
1
- require "yaml"
2
-
3
- module Textus
4
- module Maintenance
5
- # Rename a zone — rewrites the manifest's zones[] entry, rewrites
6
- # the `zone:` field on every entry under the old zone, and moves
7
- # every file from zones/<old>/ to zones/<new>/.
8
- class ZoneMv
9
- extend Textus::Contract::DSL
10
-
11
- verb :zone_mv
12
- summary "Rename a zone — manifest + files. Refuses if destination exists."
13
- surfaces :cli, :mcp
14
- cli "zone mv"
15
- arg :from, String, required: true, positional: true, description: "current zone name"
16
- arg :to, String, required: true, positional: true, description: "new zone name; refused if a zone by this name already exists"
17
- arg :dry_run, :boolean, default: false,
18
- description: "when true, returns the planned zone move without applying it; " \
19
- "defaults to false, so omitting it applies the move immediately"
20
- view { |v, _i| v.to_h }
21
-
22
- def initialize(container:, call:)
23
- @container = container
24
- @call = call
25
- @manifest = container.manifest
26
- @root = container.root
27
- end
28
-
29
- def call(from, to, dry_run: false)
30
- raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
31
- raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
32
-
33
- dest_dir = File.join(@root, "zones", to)
34
- raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
35
-
36
- affected_keys = @manifest.data.entries.select { |e| e.zone == from }.map(&:key)
37
-
38
- steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
39
- steps += affected_keys.map { |k| { "op" => "mv", "from" => k, "to" => "#{to}#{k[from.length..]}" } }
40
-
41
- plan = Plan.new(steps: steps, warnings: [])
42
- return plan if dry_run
43
-
44
- rewrite_manifest!(from, to)
45
- FileUtils.mv(File.join(@root, "zones", from), dest_dir)
46
- plan
47
- end
48
-
49
- private
50
-
51
- def rewrite_manifest!(from, to)
52
- path = File.join(@root, "manifest.yaml")
53
- raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
54
- raw["zones"].each { |z| z["name"] = to if z["name"] == from }
55
- raw["entries"].each do |e|
56
- e["zone"] = to if e["zone"] == from
57
- e["key"] = e["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
58
- e["path"] = e["path"].sub(%r{\A#{Regexp.escape(from)}(/|\z)}, "#{to}\\1")
59
- end
60
- File.write(path, YAML.dump(raw))
61
- end
62
- end
63
- end
64
- end
@@ -1,15 +0,0 @@
1
- module Textus
2
- # Bulk and structural changes to a textus store. Each use case returns
3
- # a Plan when called with dry_run: true, and applies the plan when
4
- # called with dry_run: false.
5
- module Maintenance
6
- # A Plan is a JSON-shaped preview. Steps are op-tagged hashes the
7
- # use case knows how to apply. Warnings are strings surfaced to
8
- # the operator (skipped keys, ambiguities).
9
- Plan = Data.define(:steps, :warnings) do
10
- def to_h
11
- { "steps" => steps, "warnings" => warnings }
12
- end
13
- end
14
- end
15
- end
@@ -1,70 +0,0 @@
1
- module Textus
2
- module MCP
3
- # Derives the entire MCP tool surface from the per-verb contracts
4
- # (ADR 0039). `tool_schemas` feeds tools/list; `call` is the generic
5
- # tools/call dispatch: map JSON args -> (positional, keyword) per the
6
- # contract, invoke the verb through the role scope, then shape the
7
- # return value with the contract's default view. No per-tool code.
8
- module Catalog
9
- module_function
10
-
11
- # Contracts of every MCP-surfaced verb, in Dispatcher order.
12
- def specs
13
- Textus::Dispatcher::VERBS.values
14
- .select { |k| mcp_surfaced?(k) }
15
- .map(&:contract)
16
- end
17
-
18
- def tool_schemas
19
- specs.map do |s|
20
- { name: s.verb.to_s, description: s.summary, inputSchema: s.input_schema }
21
- end.freeze
22
- end
23
-
24
- def names
25
- specs.map { |s| s.verb.to_s }
26
- end
27
-
28
- # MCP-surfaced read verbs, by Dispatcher class namespace — the agent's
29
- # real read/discovery surface. `boot.agent_quickstart.read_verbs` derives
30
- # from this so it can never advertise a verb the agent cannot call, nor
31
- # omit one it can (ADR 0056). Excludes Write/Maintenance.
32
- def read_verbs
33
- Textus::Dispatcher::VERBS
34
- .select { |_verb, klass| mcp_surfaced?(klass) && klass.name.start_with?("Textus::Read::") }
35
- .keys.map(&:to_s)
36
- end
37
-
38
- # MCP-surfaced write verbs, by Dispatcher class namespace — the mirror of
39
- # read_verbs for the write side. `boot.agent_quickstart.write_verbs` derives
40
- # from this so it advertises bare verb names the agent can call (no `--as`/
41
- # `--stdin` CLI framing), finishing the de-CLI-ing of the agent surface
42
- # (ADR 0056, ADR 0057).
43
- def write_verbs
44
- Textus::Dispatcher::VERBS
45
- .select { |_verb, klass| mcp_surfaced?(klass) && klass.name.start_with?("Textus::Write::") }
46
- .keys.map(&:to_s)
47
- end
48
-
49
- def mcp_surfaced?(klass)
50
- klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
51
- end
52
-
53
- def call(name, session:, store:, args:)
54
- klass = Textus::Dispatcher::VERBS[name.to_sym]
55
- raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
56
-
57
- spec = klass.contract
58
- inputs = Textus::Contract::Binder.inputs_from_wire(spec, args)
59
- result = store.as(session.role).dispatch_bound(spec.verb, inputs, session: session)
60
- Textus::Contract::View.render(spec, :default, result, inputs)
61
- rescue Textus::Contract::MissingArgs => e
62
- raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
63
- rescue ContractDrift, CursorExpired
64
- raise
65
- rescue Textus::Error => e
66
- raise ToolError.new("#{name}: #{e.message}")
67
- end
68
- end
69
- end
70
- end
@@ -1,32 +0,0 @@
1
- module Textus
2
- module MCP
3
- # Manifest fingerprint changed mid-session. Client should re-boot.
4
- class ContractDrift < Textus::Error
5
- JSONRPC_CODE = -32_001
6
-
7
- def initialize(message, details: {})
8
- super("contract_drift", message, details: details)
9
- end
10
- end
11
-
12
- # Audit cursor fell off the keep window. Client should re-boot and
13
- # resume from the new latest_seq.
14
- class CursorExpired < Textus::Error
15
- JSONRPC_CODE = -32_002
16
-
17
- def initialize(message, details: {})
18
- super("cursor_expired", message, details: details)
19
- end
20
- end
21
-
22
- # Tool execution failed (validation, authorization, IO). Wraps an
23
- # underlying Textus::Error or generic StandardError.
24
- class ToolError < Textus::Error
25
- JSONRPC_CODE = -32_000
26
-
27
- def initialize(message, details: {})
28
- super("tool_error", message, details: details)
29
- end
30
- end
31
- end
32
- end
@@ -1,138 +0,0 @@
1
- require "json"
2
-
3
- module Textus
4
- module MCP
5
- # Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
6
- # message (NDJSON). Holds a single Session for the lifetime of stdin.
7
- class Server
8
- PROTOCOL_VERSION = "2024-11-05"
9
- SERVER_INFO = { "name" => "textus", "version" => Textus::VERSION }.freeze
10
-
11
- def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
12
- @store = store
13
- @stdin = stdin
14
- @stdout = stdout
15
- @role = role
16
- @session = nil
17
- end
18
-
19
- def run
20
- @stdin.each_line do |line|
21
- line = line.strip
22
- next if line.empty?
23
-
24
- handle_line(line)
25
- end
26
- end
27
-
28
- private
29
-
30
- def handle_line(line)
31
- msg = JSON.parse(line)
32
- rescue JSON::ParserError => e
33
- emit_error(nil, -32_700, "parse error: #{e.message}")
34
- else
35
- dispatch(msg)
36
- end
37
-
38
- def dispatch(msg)
39
- rid = msg["id"]
40
- case msg["method"]
41
- when "initialize" then handle_initialize(rid, msg["params"] || {})
42
- when "tools/list" then handle_tools_list(rid)
43
- when "tools/call" then handle_tools_call(rid, msg["params"] || {})
44
- when "ping" then emit_result(rid, {})
45
- when "shutdown" then emit_result(rid, nil)
46
- when "notifications/initialized" then nil
47
- else emit_error(rid, -32_601, "method not found: #{msg["method"]}")
48
- end
49
- end
50
-
51
- def handle_initialize(rid, _params)
52
- # The acting role IS the resolved connection role (ADR 0040): the MCP
53
- # transport defaults to `agent`, which can write the queue, so its
54
- # propose_zone resolves directly. If a connection's role cannot propose,
55
- # propose_zone is nil and the `propose` tool reports that honestly.
56
- propose_zone = @store.manifest.policy.propose_zone_for(@role)
57
-
58
- @session = Session.new(
59
- role: @role,
60
- cursor: @store.audit_log.latest_seq,
61
- propose_zone: propose_zone,
62
- contract_etag: contract_etag,
63
- )
64
-
65
- # ADR 0075: announce the connection to connect-time hooks with the
66
- # resolved role. Distinct from :store_loaded (fired at Store.new under
67
- # the default role, before any connection's role is known).
68
- @store.events.publish(
69
- :session_opened,
70
- ctx: Hooks::Context.new(scope: @store.as(@role)),
71
- role: @role,
72
- cursor: @session.cursor,
73
- )
74
-
75
- emit_result(rid, {
76
- "protocolVersion" => PROTOCOL_VERSION,
77
- "serverInfo" => SERVER_INFO,
78
- "capabilities" => { "tools" => {} },
79
- })
80
- end
81
-
82
- def handle_tools_list(rid)
83
- emit_result(rid, { "tools" => Catalog.tool_schemas })
84
- end
85
-
86
- def handle_tools_call(rid, params)
87
- unless @session
88
- emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
89
- return
90
- end
91
-
92
- name = params["name"]
93
- args = params["arguments"] || {}
94
-
95
- # ADR 0083: the contract-drift guard gates mutating verbs — every MCP
96
- # verb that is NOT a pure read (Write:: + the destructive Maintenance::
97
- # verbs drain/zone_mv/key_*_prefix). Reads and boot bypass it (a stale
98
- # read returns on-disk truth; boot re-orients). Keying on read_verbs
99
- # (not write_verbs) keeps the destructive Maintenance:: verbs gated.
100
- @session.check_etag!(contract_etag) unless Catalog.read_verbs.include?(name)
101
-
102
- result = Catalog.call(name, session: @session, store: @store, args: args)
103
- @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
104
- @session = @session.with(contract_etag: contract_etag) if name == "boot"
105
-
106
- emit_result(rid, {
107
- "content" => [{ "type" => "text", "text" => JSON.dump(result) }],
108
- "isError" => false,
109
- })
110
- rescue ContractDrift => e
111
- emit_error(rid, ContractDrift::JSONRPC_CODE, e.message)
112
- rescue CursorExpired => e
113
- emit_error(rid, CursorExpired::JSONRPC_CODE, e.message)
114
- rescue ToolError => e
115
- emit_error(rid, ToolError::JSONRPC_CODE, e.message)
116
- rescue StandardError => e
117
- emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
118
- end
119
-
120
- def contract_etag
121
- Textus::Etag.for_contract(@store.root)
122
- end
123
-
124
- def emit_result(rid, result)
125
- write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
126
- end
127
-
128
- def emit_error(rid, code, message)
129
- write({ "jsonrpc" => "2.0", "id" => rid, "error" => { "code" => code, "message" => message } })
130
- end
131
-
132
- def write(obj)
133
- @stdout.puts(JSON.dump(obj))
134
- @stdout.flush
135
- end
136
- end
137
- end
138
- end
@@ -1,7 +0,0 @@
1
- module Textus
2
- module MCP
3
- # The session value now lives in core (ADR 0036); retained here as an
4
- # alias so existing MCP references keep resolving.
5
- Session = Textus::Session
6
- end
7
- end
@@ -1,15 +0,0 @@
1
- module Textus
2
- module MCP
3
- # Kept for name stability (ADR 0039). The JSON schemas are DERIVED from
4
- # per-verb contracts; this delegates to MCP::Catalog. The hand-written
5
- # array is gone — a kwarg rename now updates the schema automatically (and
6
- # the signature guard fails if the contract lags the use-case).
7
- module ToolSchemas
8
- module_function
9
-
10
- def all
11
- Catalog.tool_schemas
12
- end
13
- end
14
- end
15
- end
data/lib/textus/mcp.rb DELETED
@@ -1,6 +0,0 @@
1
- module Textus
2
- # The agent gate. Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05.
3
- # Wraps Textus::RoleScope as auto-derived tools. See ADR 0015.
4
- module MCP
5
- end
6
- end
@@ -1,117 +0,0 @@
1
- module Textus
2
- module Mustache
3
- MAX_DEPTH = 8
4
- TAG = %r{\{\{(?<sigil>[#^/!&]?)\s*(?<name>[\w.-]+)\s*\}\}}
5
-
6
- def self.render(template, context, strict: false, depth: 0) # rubocop:disable Metrics/AbcSize
7
- raise TemplateError.new("template recursion depth #{depth} exceeded #{MAX_DEPTH}") if depth > MAX_DEPTH
8
-
9
- out = +""
10
- pos = 0
11
- while (m = template.match(TAG, pos))
12
- out << template[pos...m.begin(0)]
13
- case m[:sigil]
14
- when "!"
15
- # comment, skip
16
- when "#"
17
- section, new_pos = parse_section(template, m, m[:name])
18
- value = lookup(context, m[:name])
19
- out << render_section(section, value, context, strict, depth)
20
- pos = new_pos
21
- next
22
- when "^"
23
- section, new_pos = parse_section(template, m, m[:name])
24
- value = lookup(context, m[:name])
25
- if falsy?(value)
26
- raise TemplateError.new("template recursion depth #{depth + 1} exceeded #{MAX_DEPTH}") if depth + 1 > MAX_DEPTH
27
-
28
- out << render(section, context, strict: strict, depth: depth + 1)
29
- end
30
- pos = new_pos
31
- next
32
- when "/"
33
- raise TemplateError.new("unexpected closing tag #{m[:name]}")
34
- else
35
- val = lookup(context, m[:name])
36
- if val.nil?
37
- raise TemplateError.new("missing variable: #{m[:name]}") if strict
38
- else
39
- out << val.to_s
40
- end
41
- end
42
- pos = m.end(0)
43
- end
44
- out << template[pos..]
45
- out
46
- end
47
-
48
- def self.parse_section(template, open_match, name)
49
- open_re = /\{\{#\s*#{Regexp.escape(name)}\s*\}\}|\{\{\^\s*#{Regexp.escape(name)}\s*\}\}/
50
- close_re = %r{\{\{/\s*#{Regexp.escape(name)}\s*\}\}}
51
- both = Regexp.union(open_re, close_re)
52
- depth = 1
53
- cursor = open_match.end(0)
54
- while (m = template.match(both, cursor))
55
- if m[0].start_with?("{{/")
56
- depth -= 1
57
- return [template[open_match.end(0)...m.begin(0)], m.end(0)] if depth.zero?
58
- else
59
- depth += 1
60
- end
61
- cursor = m.end(0)
62
- end
63
- raise TemplateError.new("unclosed section: #{name}")
64
- end
65
-
66
- def self.render_section(section, value, context, strict, depth)
67
- raise TemplateError.new("template recursion depth #{depth + 1} exceeded #{MAX_DEPTH}") if depth + 1 > MAX_DEPTH
68
-
69
- case value
70
- when Array
71
- value.map { |v| render(section, scope_for(context, v), strict: strict, depth: depth + 1) }.join
72
- when Hash
73
- render(section, merge(context, value), strict: strict, depth: depth + 1)
74
- when true
75
- render(section, context, strict: strict, depth: depth + 1)
76
- when false, nil
77
- # falsy in regular section: render nothing.
78
- # render_section is only called for inverted sections when falsy? is true at the call site,
79
- # so this branch is only hit for normal sections with falsy values.
80
- ""
81
- else
82
- render(section, context, strict: strict, depth: depth + 1)
83
- end || ""
84
- end
85
-
86
- def self.lookup(context, name)
87
- # Implicit iterator: {{.}} refers to the current scope itself (used when
88
- # iterating arrays of primitive values).
89
- return context["."] if name == "." && context.is_a?(Hash) && context.key?(".")
90
- return context[name] if context.is_a?(Hash) && context.key?(name)
91
-
92
- name.split(".").reduce(context) do |acc, seg|
93
- return nil unless acc.is_a?(Hash)
94
-
95
- acc[seg]
96
- end
97
- end
98
-
99
- # Build the rendering scope for one iteration of a section. Hash items
100
- # merge into the outer context; primitive items (strings, numbers) bind
101
- # to the implicit iterator under key ".".
102
- def self.scope_for(context, item)
103
- return merge(context, item) if item.is_a?(Hash)
104
-
105
- base = context.is_a?(Hash) ? context : {}
106
- base.merge("." => item)
107
- end
108
-
109
- def self.merge(base, override)
110
- return base unless override.is_a?(Hash)
111
-
112
- base.merge(override)
113
- end
114
-
115
- def self.falsy?(v) = v.nil? || v == false || v == [] || v == ""
116
- end
117
- end
@@ -1,73 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Ports
5
- # ADR 0093 / job-queue model: on a canon write, enqueue a `materialize` job
6
- # for each derived entry that depends on the written key (rdeps ∩ producible).
7
- # Async-only — the write returns immediately; a worker (drain/serve) converges
8
- # the jobs. There is no inline `sync` path and no in-process thread: freshness
9
- # is re-homed to drain (at the commit/CI gate) and the daemon. A write INTO a
10
- # derived entry does not fan out (recursion guard). Produce self-elevates, so
11
- # the job is stamped automation. Attached at Store boot, alongside
12
- # AuditSubscriber.
13
- class ProduceOnWriteSubscriber
14
- def initialize(container)
15
- @container = container
16
- end
17
-
18
- def attach(bus)
19
- bus.on(:entry_written, :produce_on_write) do |key:, **|
20
- on_write(key: key)
21
- end
22
- # Closes the ADR 0087 gap: a delete/rename of a source must re-materialize
23
- # its orphaned dependents too, not just a write. These fire distinct
24
- # events (:entry_deleted / :entry_renamed), so subscribe to each.
25
- bus.on(:entry_deleted, :produce_on_delete) do |key:, **|
26
- on_write(key: key)
27
- end
28
- bus.on(:entry_renamed, :produce_on_rename) do |from_key:, to_key:, **|
29
- on_write(key: from_key)
30
- on_write(key: to_key)
31
- end
32
- self
33
- end
34
-
35
- def on_write(key:)
36
- return if derived_write?(key) # recursion guard: produce output is not a source change
37
-
38
- affected = Textus::Read::Rdeps.new(container: @container).call(key)["rdeps"]
39
- producible = affected.select { |k| producible?(k) }
40
- return if producible.empty?
41
-
42
- queue = Textus::Ports::Queue.new(root: @container.root)
43
- producible.each do |k|
44
- queue.enqueue(
45
- Textus::Domain::Jobs::Job.new(
46
- type: "materialize", args: { "key" => k }, enqueued_by: Textus::Role::AUTOMATION,
47
- ),
48
- )
49
- end
50
- end
51
-
52
- private
53
-
54
- def derived_write?(key)
55
- @container.manifest.resolver.resolve(key).entry.derived?
56
- rescue Textus::Error
57
- false
58
- end
59
-
60
- # The producible scope mirrors Produce::Engine#produce_one: derived
61
- # entries render+publish, and nested publish_tree entries mirror their
62
- # source subtree (ADR 0047). Including the latter restores reactive
63
- # re-mirroring on a write into a tree's source — dropped when the scope
64
- # narrowed to `derived?` only.
65
- def producible?(key)
66
- entry = @container.manifest.resolver.resolve(key).entry
67
- entry.derived? || !entry.publish_tree.nil?
68
- rescue Textus::Error
69
- false
70
- end
71
- end
72
- end
73
- end
@@ -1,36 +0,0 @@
1
- module Textus
2
- module Produce
3
- # Single home for the fetch lifecycle event vocabulary (ADR 0048 D5).
4
- # Produce::Acquire::Intake (the ingest executor driven by converge + hook) emits through
5
- # this seam so the event names and payload shapes live in one place with one
6
- # derived hook context.
7
- class Events
8
- def self.from(container:, call:)
9
- new(
10
- events: container.events,
11
- hook_context: Textus::Hooks::Context.for(container: container, call: call),
12
- )
13
- end
14
-
15
- def initialize(events:, hook_context:)
16
- @events = events
17
- @hook_context = hook_context
18
- end
19
-
20
- def started(key, mode: :sync)
21
- @events.publish(:entry_fetch_started, ctx: @hook_context, key: key, mode: mode)
22
- end
23
-
24
- def failed(key, error)
25
- @events.publish(:entry_fetch_failed, ctx: @hook_context, key: key,
26
- error_class: error.class.name, error_message: error.message)
27
- end
28
-
29
- def fetched(key, envelope, change)
30
- return if change == :unchanged
31
-
32
- @events.publish(:entry_fetched, ctx: @hook_context, key: key, envelope: envelope, change: change)
33
- end
34
- end
35
- end
36
- end