textus 0.52.0 → 0.54.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 (261) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +64 -192
  5. data/docs/architecture/README.md +157 -156
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +44 -0
  8. data/lib/textus/action/audit.rb +131 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +28 -22
  11. data/lib/textus/action/boot.rb +17 -0
  12. data/lib/textus/action/data_mv.rb +69 -0
  13. data/lib/textus/action/deps.rb +34 -0
  14. data/lib/textus/action/doctor.rb +24 -0
  15. data/lib/textus/action/drain.rb +39 -0
  16. data/lib/textus/action/enqueue.rb +53 -0
  17. data/lib/textus/action/get.rb +78 -0
  18. data/lib/textus/action/ingest.rb +135 -0
  19. data/lib/textus/action/jobs.rb +36 -0
  20. data/lib/textus/action/key_delete.rb +36 -0
  21. data/lib/textus/action/key_delete_prefix.rb +44 -0
  22. data/lib/textus/action/key_mv.rb +129 -0
  23. data/lib/textus/action/key_mv_prefix.rb +57 -0
  24. data/lib/textus/action/list.rb +42 -0
  25. data/lib/textus/action/propose.rb +52 -0
  26. data/lib/textus/action/published.rb +24 -0
  27. data/lib/textus/action/pulse.rb +56 -0
  28. data/lib/textus/action/put.rb +52 -0
  29. data/lib/textus/action/rdeps.rb +39 -0
  30. data/lib/textus/action/reject.rb +37 -0
  31. data/lib/textus/action/rule_explain.rb +91 -0
  32. data/lib/textus/action/rule_lint.rb +68 -0
  33. data/lib/textus/action/rule_list.rb +42 -0
  34. data/lib/textus/action/schema_envelope.rb +29 -0
  35. data/lib/textus/action/uid.rb +33 -0
  36. data/lib/textus/action/where.rb +36 -0
  37. data/lib/textus/action/write_verb.rb +44 -0
  38. data/lib/textus/boot.rb +64 -132
  39. data/lib/textus/command.rb +41 -0
  40. data/lib/textus/container.rb +1 -1
  41. data/lib/textus/{domain → core}/duration.rb +1 -1
  42. data/lib/textus/{domain → core}/freshness/evaluator.rb +25 -41
  43. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  44. data/lib/textus/{domain → core}/freshness.rb +2 -2
  45. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  46. data/lib/textus/{domain → core}/retention.rb +2 -2
  47. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  48. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  49. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  50. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  51. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  52. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  53. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  54. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  55. data/lib/textus/doctor/check.rb +11 -9
  56. data/lib/textus/{read → doctor}/validator.rb +22 -13
  57. data/lib/textus/doctor.rb +4 -44
  58. data/lib/textus/envelope/reader.rb +46 -0
  59. data/lib/textus/envelope/writer.rb +209 -0
  60. data/lib/textus/envelope.rb +5 -3
  61. data/lib/textus/errors.rb +25 -13
  62. data/lib/textus/{entry → format}/base.rb +1 -6
  63. data/lib/textus/{entry → format}/json.rb +1 -4
  64. data/lib/textus/{entry → format}/markdown.rb +2 -6
  65. data/lib/textus/{entry → format}/text.rb +2 -6
  66. data/lib/textus/{entry → format}/yaml.rb +1 -4
  67. data/lib/textus/{entry.rb → format.rb} +9 -10
  68. data/lib/textus/gate/auth.rb +227 -0
  69. data/lib/textus/gate.rb +116 -0
  70. data/lib/textus/init.rb +59 -105
  71. data/lib/textus/jobs/base.rb +23 -0
  72. data/lib/textus/jobs/materialize.rb +20 -0
  73. data/lib/textus/jobs/plan.rb +9 -0
  74. data/lib/textus/jobs/planner.rb +101 -0
  75. data/lib/textus/jobs/retention.rb +48 -0
  76. data/lib/textus/jobs/sweep.rb +27 -0
  77. data/lib/textus/jobs/worker.rb +67 -0
  78. data/lib/textus/jobs.rb +15 -0
  79. data/lib/textus/key/path.rb +10 -3
  80. data/lib/textus/layout.rb +13 -0
  81. data/lib/textus/manifest/data.rb +14 -14
  82. data/lib/textus/manifest/entry/base.rb +14 -25
  83. data/lib/textus/manifest/entry/parser.rb +31 -14
  84. data/lib/textus/manifest/entry/produced.rb +13 -33
  85. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  86. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -3
  87. data/lib/textus/manifest/entry/publish.rb +0 -12
  88. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  89. data/lib/textus/manifest/entry/validators.rb +0 -1
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  94. data/lib/textus/manifest/policy/source.rb +30 -0
  95. data/lib/textus/manifest/policy.rb +38 -53
  96. data/lib/textus/manifest/resolver.rb +5 -4
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +20 -28
  99. data/lib/textus/manifest/schema/validator.rb +27 -33
  100. data/lib/textus/manifest/schema/vocabulary.rb +2 -1
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/ports/audit_log.rb +31 -5
  104. data/lib/textus/{domain/jobs → ports/job_store}/job.rb +19 -12
  105. data/lib/textus/ports/{queue.rb → job_store.rb} +7 -14
  106. data/lib/textus/ports/sentinel_store.rb +2 -2
  107. data/lib/textus/ports/watcher_lock.rb +48 -0
  108. data/lib/textus/produce/engine.rb +25 -70
  109. data/lib/textus/produce/render.rb +2 -7
  110. data/lib/textus/schema/tools.rb +4 -3
  111. data/lib/textus/session.rb +6 -3
  112. data/lib/textus/store.rb +12 -19
  113. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  114. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  115. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  116. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  117. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  118. data/lib/textus/surfaces/cli/group.rb +50 -0
  119. data/lib/textus/surfaces/cli/runner.rb +236 -0
  120. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  121. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  122. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  123. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  124. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  125. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  126. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  127. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  128. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  129. data/lib/textus/surfaces/cli/verb.rb +111 -0
  130. data/lib/textus/surfaces/cli.rb +148 -0
  131. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  132. data/lib/textus/surfaces/mcp/errors.rb +25 -0
  133. data/lib/textus/surfaces/mcp/routing.rb +51 -0
  134. data/lib/textus/surfaces/mcp/server.rb +184 -0
  135. data/lib/textus/surfaces/mcp/session.rb +9 -0
  136. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  137. data/lib/textus/surfaces/mcp.rb +8 -0
  138. data/lib/textus/surfaces/role_scope.rb +34 -0
  139. data/lib/textus/surfaces/watcher.rb +38 -0
  140. data/lib/textus/version.rb +1 -1
  141. data/lib/textus/workflow/collector.rb +27 -0
  142. data/lib/textus/workflow/context.rb +5 -0
  143. data/lib/textus/workflow/dsl.rb +44 -0
  144. data/lib/textus/workflow/loader.rb +17 -0
  145. data/lib/textus/workflow/pattern.rb +18 -0
  146. data/lib/textus/workflow/registry.rb +21 -0
  147. data/lib/textus/workflow/runner.rb +84 -0
  148. data/lib/textus/workflow.rb +28 -0
  149. data/lib/textus.rb +75 -22
  150. metadata +118 -132
  151. data/lib/textus/cli/group/hook.rb +0 -9
  152. data/lib/textus/cli/group/key.rb +0 -9
  153. data/lib/textus/cli/group/mcp.rb +0 -9
  154. data/lib/textus/cli/group/rule.rb +0 -9
  155. data/lib/textus/cli/group/schema.rb +0 -9
  156. data/lib/textus/cli/group/zone.rb +0 -9
  157. data/lib/textus/cli/group.rb +0 -48
  158. data/lib/textus/cli/runner.rb +0 -193
  159. data/lib/textus/cli/verb/doctor.rb +0 -17
  160. data/lib/textus/cli/verb/get.rb +0 -18
  161. data/lib/textus/cli/verb/hook_run.rb +0 -48
  162. data/lib/textus/cli/verb/hooks.rb +0 -50
  163. data/lib/textus/cli/verb/init.rb +0 -18
  164. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  165. data/lib/textus/cli/verb/put.rb +0 -30
  166. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  167. data/lib/textus/cli/verb/schema_init.rb +0 -19
  168. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  169. data/lib/textus/cli/verb/serve.rb +0 -19
  170. data/lib/textus/cli/verb.rb +0 -116
  171. data/lib/textus/cli.rb +0 -138
  172. data/lib/textus/dispatcher.rb +0 -54
  173. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  174. data/lib/textus/doctor/check/hooks.rb +0 -32
  175. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  176. data/lib/textus/domain/action.rb +0 -9
  177. data/lib/textus/domain/jobs/registry.rb +0 -37
  178. data/lib/textus/domain/permission.rb +0 -7
  179. data/lib/textus/domain/policy/base_guards.rb +0 -25
  180. data/lib/textus/domain/policy/evaluation.rb +0 -15
  181. data/lib/textus/domain/policy/guard.rb +0 -35
  182. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  183. data/lib/textus/domain/policy/handler_allowlist.rb +0 -17
  184. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  185. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  186. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  187. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  188. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  189. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  190. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  191. data/lib/textus/domain/policy/source.rb +0 -73
  192. data/lib/textus/envelope/io/reader.rb +0 -48
  193. data/lib/textus/envelope/io/writer.rb +0 -182
  194. data/lib/textus/hooks/builtin.rb +0 -70
  195. data/lib/textus/hooks/catalog.rb +0 -37
  196. data/lib/textus/hooks/context.rb +0 -72
  197. data/lib/textus/hooks/error_log.rb +0 -32
  198. data/lib/textus/hooks/event_bus.rb +0 -114
  199. data/lib/textus/hooks/fire_report.rb +0 -23
  200. data/lib/textus/hooks/loader.rb +0 -54
  201. data/lib/textus/hooks/rpc_registry.rb +0 -43
  202. data/lib/textus/hooks/signature.rb +0 -31
  203. data/lib/textus/init/templates/machine_intake.rb +0 -45
  204. data/lib/textus/init/templates/orientation_reducer.rb +0 -17
  205. data/lib/textus/jobs/handlers.rb +0 -62
  206. data/lib/textus/jobs/scheduler.rb +0 -36
  207. data/lib/textus/jobs/seeder.rb +0 -57
  208. data/lib/textus/maintenance/drain.rb +0 -42
  209. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  210. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  211. data/lib/textus/maintenance/retention/apply.rb +0 -52
  212. data/lib/textus/maintenance/rule_lint.rb +0 -66
  213. data/lib/textus/maintenance/serve.rb +0 -30
  214. data/lib/textus/maintenance/worker.rb +0 -74
  215. data/lib/textus/maintenance/zone_mv.rb +0 -64
  216. data/lib/textus/maintenance.rb +0 -15
  217. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/audit_subscriber.rb +0 -42
  226. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  227. data/lib/textus/produce/acquire/handler.rb +0 -29
  228. data/lib/textus/produce/acquire/intake.rb +0 -130
  229. data/lib/textus/produce/acquire/projection.rb +0 -127
  230. data/lib/textus/produce/acquire/serializer/json.rb +0 -31
  231. data/lib/textus/produce/acquire/serializer/text.rb +0 -16
  232. data/lib/textus/produce/acquire/serializer/yaml.rb +0 -31
  233. data/lib/textus/produce/acquire/serializer.rb +0 -17
  234. data/lib/textus/produce/events.rb +0 -36
  235. data/lib/textus/projection.rb +0 -103
  236. data/lib/textus/read/audit.rb +0 -130
  237. data/lib/textus/read/boot.rb +0 -26
  238. data/lib/textus/read/capabilities.rb +0 -70
  239. data/lib/textus/read/deps.rb +0 -38
  240. data/lib/textus/read/doctor.rb +0 -27
  241. data/lib/textus/read/freshness.rb +0 -152
  242. data/lib/textus/read/get.rb +0 -73
  243. data/lib/textus/read/jobs.rb +0 -31
  244. data/lib/textus/read/list.rb +0 -24
  245. data/lib/textus/read/published.rb +0 -22
  246. data/lib/textus/read/pulse.rb +0 -98
  247. data/lib/textus/read/rdeps.rb +0 -39
  248. data/lib/textus/read/rule_explain.rb +0 -96
  249. data/lib/textus/read/rule_list.rb +0 -54
  250. data/lib/textus/read/schema_envelope.rb +0 -25
  251. data/lib/textus/read/uid.rb +0 -29
  252. data/lib/textus/read/validate_all.rb +0 -36
  253. data/lib/textus/read/where.rb +0 -24
  254. data/lib/textus/role_scope.rb +0 -78
  255. data/lib/textus/write/accept.rb +0 -58
  256. data/lib/textus/write/enqueue.rb +0 -50
  257. data/lib/textus/write/key_delete.rb +0 -65
  258. data/lib/textus/write/key_mv.rb +0 -141
  259. data/lib/textus/write/propose.rb +0 -54
  260. data/lib/textus/write/put.rb +0 -74
  261. data/lib/textus/write/reject.rb +0 -68
@@ -0,0 +1,148 @@
1
+ require "json"
2
+ require "optparse"
3
+
4
+ module Textus
5
+ module Surfaces
6
+ class CLI
7
+ # Auto-derived verb table. Every CLI::Verb (or Group) subclass that
8
+ # declares `command_name "X"` and has no `parent_group` is a top-level
9
+ # verb. Sorted alphabetically for stable help output. Adding a new
10
+ # verb requires only a new file declaring its `command_name`.
11
+ #
12
+ # `k.name` gates out anonymous (Class.new) subclasses: real verbs are always
13
+ # named constants (generated Gen* or hand-authored classes), so this is a
14
+ # no-op in production but keeps throwaway test fixtures from leaking into the
15
+ # registry (and tripping the reconciliation guards order-dependently).
16
+ def self.verbs
17
+ Runner.install!
18
+ Verb.descendants
19
+ .select { |k| k.name && k.command_name && k.parent_group.nil? }
20
+ .sort_by(&:command_name)
21
+ .to_h { |k| [k.command_name, k] }
22
+ end
23
+
24
+ def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
25
+ new(stdin: stdin, stdout: stdout, stderr: stderr, cwd: cwd).run(argv)
26
+ end
27
+
28
+ def initialize(stdin:, stdout:, stderr:, cwd:)
29
+ @stdin = stdin
30
+ @stdout = stdout
31
+ @stderr = stderr
32
+ @cwd = cwd
33
+ @root_arg = nil
34
+ end
35
+
36
+ def run(argv)
37
+ # `--root` is a global, position-agnostic option: pull it out of argv
38
+ # wherever it appears so it works uniformly before OR after any verb or
39
+ # group (e.g. both `textus --root=X hook list` and
40
+ # `textus hook list --root=X`). Without this, `order!` below only sees
41
+ # options before the first verb token, so a trailing `--root` reached the
42
+ # verb's own parser and raised InvalidOption (#161 F5). TEXTUS_ROOT already
43
+ # works everywhere via Store.discover, so this brings the flag to parity.
44
+ @root_arg = extract_root!(argv)
45
+
46
+ # Define --version/--help ourselves so OptionParser doesn't intercept them
47
+ # with its built-in handlers (which print "version unknown" and a bare usage
48
+ # line, then exit before we ever reach the verb dispatch below).
49
+ show_version = false
50
+ show_help = false
51
+ OptionParser.new do |o|
52
+ o.on("--version", "-v") { show_version = true }
53
+ o.on("--help", "-h") { show_help = true }
54
+ end.order!(argv)
55
+
56
+ return @stdout.puts(VERSION) || 0 if show_version
57
+ return print_help || 0 if show_help
58
+
59
+ verb = argv.shift
60
+ raise UsageError.new("missing verb") if verb.nil?
61
+
62
+ klass = self.class.verbs[verb] or raise UsageError.new("unknown verb: #{verb}")
63
+ coerce_exit_code(dispatch(klass, argv))
64
+ rescue Textus::Error => e
65
+ emit_error(e)
66
+ end
67
+
68
+ private
69
+
70
+ # Remove the first `--root=PATH` or `--root PATH` token from argv (anywhere)
71
+ # and return its value, or nil if absent. Mutates argv in place.
72
+ def extract_root!(argv)
73
+ i = argv.index { |a| a == "--root" || a.start_with?("--root=") }
74
+ return nil unless i
75
+
76
+ tok = argv[i]
77
+ if tok.start_with?("--root=")
78
+ argv.delete_at(i)
79
+ tok.delete_prefix("--root=")
80
+ else
81
+ val = argv[i + 1]
82
+ raise UsageError.new("--root requires a PATH") if val.nil? || val.start_with?("-")
83
+
84
+ argv.delete_at(i + 1)
85
+ argv.delete_at(i)
86
+ val
87
+ end
88
+ end
89
+
90
+ def coerce_exit_code(value)
91
+ case value
92
+ when Integer then value
93
+ when true, nil then 0
94
+ when false then 1
95
+ else
96
+ @stderr.puts("warning: verb returned non-Integer #{value.class}; treating as 0")
97
+ 0
98
+ end
99
+ end
100
+
101
+ def store
102
+ @store ||= Store.discover(@cwd, root: @root_arg)
103
+ end
104
+
105
+ def dispatch(klass, argv)
106
+ v = klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
107
+ v.parse(argv)
108
+ v.call(klass.needs_store? ? store : nil)
109
+ end
110
+
111
+ def emit_error(err)
112
+ @stdout.puts(JSON.generate(err.to_envelope))
113
+ @stderr.puts("#{err.code}: #{err.message}")
114
+ @stderr.puts(" → #{err.hint}") if err.hint
115
+ err.exit_code
116
+ end
117
+
118
+ def print_help
119
+ @stdout.puts <<~HELP
120
+ textus #{VERSION} — reference implementation of #{PROTOCOL}
121
+
122
+ Usage (json output is the default):
123
+ textus list [--prefix=KEY] [--lane=LANE]
124
+ textus where KEY
125
+ textus get KEY
126
+ textus put KEY --stdin --as=ROLE
127
+ textus propose KEY --stdin --as=ROLE
128
+ textus accept KEY --as=ROLE
129
+ textus reject KEY --as=ROLE
130
+ textus audit [--key=K] [--lane=LANE] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
131
+ textus blame KEY [--limit=N]
132
+ textus pulse [--since=N]
133
+ textus boot
134
+ textus doctor
135
+ textus drain [PREFIX] --as=ROLE
136
+ textus watch
137
+ textus jobs
138
+
139
+ textus key {delete,mv,mv-prefix,delete-prefix,uid}
140
+ textus rule {explain,lint,list}
141
+ textus schema {diff,init,migrate,show}
142
+ textus data {mv}
143
+ textus mcp {serve}
144
+ HELP
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,99 @@
1
+ module Textus
2
+ module Surfaces
3
+ module MCP
4
+ # Derives the entire MCP tool surface from the per-verb contracts
5
+ # (ADR 0039). `tool_schemas` feeds tools/list; `call` is the generic
6
+ # tools/call dispatch: map JSON args -> (positional, keyword) per the
7
+ # contract, invoke the verb through the role scope, then shape the
8
+ # return value with the contract's default view. No per-tool code.
9
+ module Catalog
10
+ module_function
11
+
12
+ WRITE_VERBS = %i[
13
+ put propose key_delete key_mv accept reject enqueue
14
+ ].freeze
15
+
16
+ MAINTENANCE_VERBS = %i[
17
+ data_mv key_mv_prefix key_delete_prefix drain rule_lint
18
+ ].freeze
19
+
20
+ # Contracts of every MCP-surfaced verb, in Dispatcher order.
21
+ def specs
22
+ Textus::Action::VERBS.values
23
+ .select { |k| mcp_surfaced?(k) }
24
+ .map(&:contract)
25
+ end
26
+
27
+ def tool_schemas
28
+ specs.map do |s|
29
+ { name: s.verb.to_s, description: s.summary, inputSchema: s.input_schema }
30
+ end.freeze
31
+ end
32
+
33
+ def names
34
+ specs.map { |s| s.verb.to_s }
35
+ end
36
+
37
+ # MCP-surfaced read verbs, by Dispatcher class namespace — the agent's
38
+ # real read/discovery surface. `boot.agent_quickstart.read_verbs` derives
39
+ # from this so it can never advertise a verb the agent cannot call, nor
40
+ # omit one it can (ADR 0056). Excludes write/maintenance verbs by verb
41
+ # identity (routing may be legacy UseCases or Dispatch::Actions).
42
+ def read_verbs
43
+ Textus::Action::VERBS
44
+ .reject { |verb, _klass| WRITE_VERBS.include?(verb) || MAINTENANCE_VERBS.include?(verb) }
45
+ .select { |_verb, klass| mcp_surfaced?(klass) }
46
+ .keys.map(&:to_s)
47
+ end
48
+
49
+ # MCP-surfaced write verbs, by Dispatcher class namespace — the mirror of
50
+ # read_verbs for the write side. `boot.agent_quickstart.write_verbs` derives
51
+ # from this so it advertises bare verb names the agent can call (no `--as`/
52
+ # `--stdin` CLI framing), finishing the de-CLI-ing of the agent surface
53
+ # (ADR 0056, ADR 0057).
54
+ def write_verbs
55
+ Textus::Action::VERBS
56
+ .select { |verb, klass| WRITE_VERBS.include?(verb) && mcp_surfaced?(klass) }
57
+ .keys.map(&:to_s)
58
+ end
59
+
60
+ def mcp_surfaced?(klass)
61
+ klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
62
+ end
63
+
64
+ def call(name, session:, store:, args:) # rubocop:disable Metrics/AbcSize
65
+ klass = Textus::Action::VERBS[name.to_sym]
66
+ raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
67
+
68
+ spec = klass.contract
69
+ inputs = Textus::Contract::Binder.inputs_from_wire(spec, args)
70
+
71
+ invoke = lambda do |effective_inputs|
72
+ pos, kwargs = Textus::Contract::Binder.bind(spec, effective_inputs, session: session)
73
+ spec.args.select(&:positional).zip(pos).each { |a, v| kwargs[a.name] = v unless kwargs.key?(a.name) }
74
+ cmd_class = Textus::Gate::VERB_COMMAND.fetch(spec.verb) do
75
+ raise Textus::MCP::ToolError.new("unknown verb: #{spec.verb}")
76
+ end
77
+ merged = kwargs.merge(role: session.role)
78
+ filled = cmd_class.members.to_h { |m| [m, merged.key?(m) ? merged[m] : nil] }
79
+ cmd = cmd_class.new(**filled)
80
+ store.gate.dispatch(cmd)
81
+ end
82
+
83
+ result = if spec.around
84
+ Textus::Contract::Around.with(spec.around, scope: store.as(session.role), inputs: inputs, session: session, &invoke)
85
+ else
86
+ invoke.call(inputs)
87
+ end
88
+ Textus::Contract::View.render(spec, :default, result, inputs)
89
+ rescue Textus::Contract::MissingArgs => e
90
+ raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
91
+ rescue Textus::ContractDrift, CursorExpired
92
+ raise
93
+ rescue Textus::Error => e
94
+ raise ToolError.new("#{name}: #{e.message}")
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ module Surfaces
3
+ module MCP
4
+ # Audit cursor fell off the keep window. Client should re-boot and
5
+ # resume from the new latest_seq.
6
+ class CursorExpired < Textus::Error
7
+ JSONRPC_CODE = -32_002
8
+
9
+ def initialize(message, details: {})
10
+ super("cursor_expired", message, details: details)
11
+ end
12
+ end
13
+
14
+ # Tool execution failed (validation, authorization, IO). Wraps an
15
+ # underlying Textus::Error or generic StandardError.
16
+ class ToolError < Textus::Error
17
+ JSONRPC_CODE = -32_000
18
+
19
+ def initialize(message, details: {})
20
+ super("tool_error", message, details: details)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,51 @@
1
+ module Textus
2
+ module Surfaces
3
+ module MCP
4
+ # Protocol routing for the MCP JSON-RPC server. Mixed into Server so all
5
+ # handle_* and emit_* methods are in scope without exposing them publicly.
6
+ module Routing
7
+ TOOL_METHODS = %w[initialize tools/list tools/call].freeze
8
+ RESOURCE_METHODS = %w[resources/list resources/read].freeze
9
+
10
+ def dispatch(msg)
11
+ rid = msg["id"]
12
+ params = msg["params"] || {}
13
+ route(msg["method"], rid, params)
14
+ end
15
+
16
+ private
17
+
18
+ def route(method, rid, params)
19
+ return route_tool(method, rid, params) if TOOL_METHODS.include?(method)
20
+ return route_resource(method, rid, params) if RESOURCE_METHODS.include?(method)
21
+
22
+ route_protocol(method, rid)
23
+ end
24
+
25
+ def route_tool(method, rid, params)
26
+ case method
27
+ when "initialize" then handle_initialize(rid, params)
28
+ when "tools/list" then handle_tools_list(rid)
29
+ when "tools/call" then handle_tools_call(rid, params)
30
+ end
31
+ end
32
+
33
+ def route_resource(method, rid, params)
34
+ case method
35
+ when "resources/list" then handle_resources_list(rid)
36
+ when "resources/read" then handle_resources_read(rid, params)
37
+ end
38
+ end
39
+
40
+ def route_protocol(method, rid)
41
+ case method
42
+ when "ping" then emit_result(rid, {})
43
+ when "shutdown" then emit_result(rid, nil)
44
+ when "notifications/initialized" then nil
45
+ else emit_error(rid, -32_601, "method not found: #{method}")
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,184 @@
1
+ require "json"
2
+ require_relative "routing"
3
+
4
+ module Textus
5
+ module Surfaces
6
+ module MCP
7
+ # Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
8
+ # message (NDJSON). Holds a single Session for the lifetime of stdin.
9
+ class Server
10
+ include Routing
11
+
12
+ PROTOCOL_VERSION = "2024-11-05"
13
+ SERVER_INFO = { "name" => "textus", "version" => Textus::VERSION }.freeze
14
+ MAX_LINE_BYTES = 1_048_576 # 1 MB — protects against OOM from oversized tool calls
15
+
16
+ def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
17
+ @store = store
18
+ @stdin = stdin
19
+ @stdout = stdout
20
+ @role = role
21
+ @session = nil
22
+ end
23
+
24
+ def run
25
+ @stdin.each_line do |line|
26
+ line = line.strip
27
+ next if line.empty?
28
+
29
+ handle_line(line)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def handle_line(line)
36
+ return reject_oversized(line) if line.bytesize > MAX_LINE_BYTES
37
+
38
+ parse_and_dispatch(line)
39
+ end
40
+
41
+ def reject_oversized(line)
42
+ emit_error(nil, -32_700, "message too large (#{line.bytesize} bytes, limit #{MAX_LINE_BYTES})")
43
+ end
44
+
45
+ def parse_and_dispatch(line)
46
+ dispatch(JSON.parse(line))
47
+ rescue JSON::ParserError => e
48
+ emit_error(nil, -32_700, "parse error: #{e.message}")
49
+ end
50
+
51
+ def handle_initialize(rid, _params)
52
+ @session = build_session
53
+ emit_result(rid, {
54
+ "protocolVersion" => PROTOCOL_VERSION,
55
+ "serverInfo" => SERVER_INFO,
56
+ "capabilities" => { "tools" => {}, "resources" => {} },
57
+ })
58
+ end
59
+
60
+ def build_session
61
+ # The acting role IS the resolved connection role (ADR 0040): the MCP
62
+ # transport defaults to `agent`, which can write the queue, so its
63
+ # propose_lane resolves directly. If a connection's role cannot propose,
64
+ # propose_lane is nil and the `propose` tool reports that honestly.
65
+ Session.new(
66
+ role: @role,
67
+ cursor: @store.audit_log.latest_seq,
68
+ propose_lane: @store.manifest.policy.propose_lane_for(@role),
69
+ contract_etag: contract_etag,
70
+ )
71
+ end
72
+
73
+ def handle_tools_list(rid)
74
+ emit_result(rid, { "tools" => Catalog.tool_schemas })
75
+ end
76
+
77
+ def handle_tools_call(rid, params)
78
+ return unless session_ready?(rid)
79
+
80
+ invoke_tool(rid, params["name"], params["arguments"] || {})
81
+ rescue Textus::ContractDrift, CursorExpired, ToolError => e
82
+ emit_error(rid, e.class::JSONRPC_CODE, e.message)
83
+ rescue StandardError => e
84
+ emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
85
+ end
86
+
87
+ def session_ready?(rid)
88
+ return true if @session
89
+
90
+ emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
91
+ false
92
+ end
93
+
94
+ def invoke_tool(rid, name, args)
95
+ # ADR 0083: contract-drift guard gates mutating verbs only
96
+ @session.check_etag!(contract_etag) unless Catalog.read_verbs.include?(name)
97
+ result = Catalog.call(name, session: @session, store: @store, args: args)
98
+ update_session_for(name)
99
+ emit_tool_result(rid, result)
100
+ end
101
+
102
+ def update_session_for(name)
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
+ end
106
+
107
+ def emit_tool_result(rid, result)
108
+ emit_result(rid, {
109
+ "content" => [{ "type" => "text", "text" => JSON.dump(result) }],
110
+ "isError" => false,
111
+ })
112
+ end
113
+
114
+ def handle_resources_list(rid)
115
+ emit_result(rid, { "resources" => machine_resources })
116
+ end
117
+
118
+ def machine_resources
119
+ machine_lane = @store.manifest.policy.machine_lane
120
+ return [] unless machine_lane
121
+
122
+ produced_entries(machine_lane).map { |e| resource_descriptor(e) }
123
+ end
124
+
125
+ def produced_entries(machine_lane)
126
+ @store.manifest.data.entries
127
+ .select { |e| e.lane == machine_lane && e.is_a?(Textus::Manifest::Entry::Produced) }
128
+ end
129
+
130
+ def resource_descriptor(entry)
131
+ {
132
+ "uri" => "textus://#{entry.key.tr(".", "/")}",
133
+ "name" => entry.key,
134
+ "mimeType" => mime_for_format(entry.format),
135
+ }
136
+ end
137
+
138
+ def handle_resources_read(rid, params)
139
+ uri = params["uri"].to_s
140
+ key = uri.delete_prefix("textus://").tr("/", ".")
141
+ emit_result(rid, resource_contents(uri, key))
142
+ rescue Textus::Error => e
143
+ emit_error(rid, ToolError::JSONRPC_CODE, "resource read failed: #{e.message}")
144
+ end
145
+
146
+ def resource_contents(uri, key)
147
+ env = @store.as(@role).get(key)
148
+ text = resource_text(env.content || env.body || "")
149
+ mime = mime_for_format(@store.manifest.resolver.resolve(key).entry.format)
150
+ { "contents" => [{ "uri" => uri, "mimeType" => mime, "text" => text }] }
151
+ end
152
+
153
+ def resource_text(content)
154
+ content.is_a?(Hash) ? JSON.dump(content) : content.to_s
155
+ end
156
+
157
+ def contract_etag
158
+ Textus::Etag.for_contract(@store.root)
159
+ end
160
+
161
+ def mime_for_format(format)
162
+ case format.to_s
163
+ when "json" then "application/json"
164
+ when "yaml" then "application/yaml"
165
+ else "text/plain"
166
+ end
167
+ end
168
+
169
+ def emit_result(rid, result)
170
+ write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
171
+ end
172
+
173
+ def emit_error(rid, code, message)
174
+ write({ "jsonrpc" => "2.0", "id" => rid, "error" => { "code" => code, "message" => message } })
175
+ end
176
+
177
+ def write(obj)
178
+ @stdout.puts(JSON.dump(obj))
179
+ @stdout.flush
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,9 @@
1
+ module Textus
2
+ module Surfaces
3
+ module MCP
4
+ # The session value now lives in core (ADR 0036); retained here as an
5
+ # alias so existing MCP references keep resolving.
6
+ Session = Textus::Session
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Surfaces
3
+ module MCP
4
+ # Kept for name stability (ADR 0039). The JSON schemas are DERIVED from
5
+ # per-verb contracts; this delegates to MCP::Catalog. The hand-written
6
+ # array is gone — a kwarg rename now updates the schema automatically (and
7
+ # the signature guard fails if the contract lags the use-case).
8
+ module ToolSchemas
9
+ module_function
10
+
11
+ def all
12
+ Catalog.tool_schemas
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,8 @@
1
+ module Textus
2
+ module Surfaces
3
+ # The agent gate. Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05.
4
+ # Wraps Textus::Surfaces::RoleScope as auto-derived tools. See ADR 0015.
5
+ module MCP
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Surfaces
5
+ # Role-scoped identity carrier. Holds the acting identity (role,
6
+ # correlation_id, dry_run) bound to a container. All verb methods
7
+ # (put, get, accept, ...) are injected by textus.rb's define_method
8
+ # loop, which dispatches directly through Gate.
9
+ class RoleScope
10
+ attr_reader :container, :role, :correlation_id
11
+
12
+ def initialize(container:, role:, dry_run: false, correlation_id: nil)
13
+ @container = container
14
+ @role = role.to_s
15
+ @dry_run = dry_run
16
+ @correlation_id = correlation_id || SecureRandom.uuid
17
+ end
18
+
19
+ def dry_run? = !!@dry_run
20
+
21
+ def with_role(role)
22
+ self.class.new(container: @container, role:, dry_run: @dry_run, correlation_id: @correlation_id)
23
+ end
24
+
25
+ def with_correlation_id(cid)
26
+ self.class.new(container: @container, role: @role, dry_run: @dry_run, correlation_id: cid)
27
+ end
28
+
29
+ def with_dry_run
30
+ self.class.new(container: @container, role: @role, dry_run: true, correlation_id: @correlation_id)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Textus
6
+ module Surfaces
7
+ class Watcher
8
+ def initialize(container:)
9
+ @container = container
10
+ @queue = Textus::Ports::JobStore.new(root: container.root)
11
+ end
12
+
13
+ def tick
14
+ Textus::Jobs::Planner.seed(
15
+ container: @container,
16
+ queue: @queue,
17
+ role: Textus::Role::AUTOMATION,
18
+ )
19
+ @queue.reclaim(now: Textus::Ports::Clock.new.now)
20
+ Textus::Jobs::Worker.for(container: @container, queue: @queue).drain
21
+ end
22
+
23
+ def run(poll: nil)
24
+ interval = poll || @container.manifest.data.worker_config[:poll]
25
+ lock = Textus::Ports::WatcherLock.new(@container.root)
26
+ lock.acquire
27
+ begin
28
+ loop do
29
+ tick
30
+ sleep(interval)
31
+ end
32
+ ensure
33
+ lock.release
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.52.0"
2
+ VERSION = "0.54.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -0,0 +1,27 @@
1
+ module Textus
2
+ module Workflow
3
+ class Collector
4
+ @current = nil
5
+
6
+ class << self
7
+ attr_reader :current
8
+ end
9
+
10
+ def self.with(collector)
11
+ prev = @current
12
+ @current = collector
13
+ yield
14
+ ensure
15
+ @current = prev
16
+ end
17
+
18
+ def initialize(registry)
19
+ @registry = registry
20
+ end
21
+
22
+ def register(defn)
23
+ @registry.register(defn)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ module Textus
2
+ module Workflow
3
+ Context = Data.define(:key, :entry, :config, :lane, :container, :call)
4
+ end
5
+ end