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
@@ -0,0 +1,111 @@
1
+ require "json"
2
+ require "optparse"
3
+
4
+ module Textus
5
+ module Surfaces
6
+ class CLI
7
+ # Subclasses must implement #call(store) and return an integer exit code.
8
+ # Use #emit(obj) for normal JSON output (returns 0).
9
+ # Subclasses that don't need a Textus store (e.g. Init) override
10
+ # `.needs_store?` to return false; dispatch will pass nil instead.
11
+ class Verb
12
+ class << self
13
+ def option(name, optspec)
14
+ options << [name, optspec]
15
+ attr_accessor(name)
16
+ end
17
+
18
+ def options
19
+ @options ||= []
20
+ end
21
+
22
+ def needs_store?
23
+ true
24
+ end
25
+
26
+ # Declarative CLI name. Reader returns the registered name (or nil
27
+ # for verbs that aren't directly invokable, like the abstract
28
+ # Verb/Group base classes). Writer registers it.
29
+ def command_name(name = nil)
30
+ if name.nil?
31
+ @command_name
32
+ else
33
+ @command_name = name.to_s
34
+ end
35
+ end
36
+
37
+ # Declares that this verb is a subcommand of `group_klass`. When
38
+ # set, the verb is NOT a top-level CLI verb — it's listed under
39
+ # the group's subcommands instead.
40
+ def parent_group(group_klass = nil)
41
+ if group_klass.nil?
42
+ @parent_group
43
+ else
44
+ @parent_group = group_klass
45
+ end
46
+ end
47
+
48
+ def inherited(subclass)
49
+ super
50
+ subclass.instance_variable_set(:@options, [])
51
+ subclass.instance_variable_set(:@command_name, nil)
52
+ subclass.instance_variable_set(:@parent_group, nil)
53
+ end
54
+
55
+ # Recursive subclass enumeration. Ruby 3.1 ships Class#subclasses
56
+ # but not Class#descendants, so we expand it ourselves.
57
+ def descendants
58
+ subclasses.flat_map { |k| [k] + k.descendants }
59
+ end
60
+ end
61
+
62
+ def initialize(stdin:, stdout:, stderr:, cwd: nil)
63
+ @stdin = stdin
64
+ @stdout = stdout
65
+ @stderr = stderr
66
+ @cwd = cwd
67
+ end
68
+
69
+ def parse(argv)
70
+ fmt = "json"
71
+ OptionParser.new do |o|
72
+ self.class.options.each do |name, optspec|
73
+ o.on(optspec) { |v| public_send(:"#{name}=", v) }
74
+ end
75
+ o.on("--output=FMT") { |v| fmt = v }
76
+ o.on("--format=FMT") { |_v| raise FlagRenamed.new("--format", "--output") }
77
+ end.permute!(argv)
78
+ raise UsageError.new("only --output=json is supported in v1") unless fmt == "json"
79
+
80
+ @positional = argv.dup
81
+ end
82
+
83
+ attr_reader :positional
84
+
85
+ # Hashes get "protocol" => PROTOCOL prepended unless they already
86
+ # carry one (Store envelopes do). Caller's value wins on collision.
87
+ def emit(obj, exit_code: 0)
88
+ payload = obj.is_a?(Hash) ? { "protocol" => PROTOCOL }.merge(obj) : obj
89
+ @stdout.puts(JSON.generate(payload))
90
+ exit_code
91
+ end
92
+
93
+ # Resolves the active role for this invocation. Honors the verb's
94
+ # `--as` flag if declared, then TEXTUS_ROLE, then the project default.
95
+ # Pass `default:` to override the fallback (e.g. MCPServe uses AGENT).
96
+ def resolved_role(store, default: Role::DEFAULT)
97
+ flag = respond_to?(:as_flag) ? as_flag : nil
98
+ Role.resolve(flag: flag, env: ENV, root: store.root, default: default)
99
+ end
100
+
101
+ # Builds a Command from spec + inputs and dispatches through Gate.
102
+ def gate_dispatch(cmd, store)
103
+ store.gate.dispatch(cmd)
104
+ end
105
+
106
+ # The input stream — the source for a `cli_stdin` envelope (ADR 0068).
107
+ attr_reader :stdin
108
+ end
109
+ end
110
+ end
111
+ end
@@ -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 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,34 @@
1
+ module Textus
2
+ module Surfaces
3
+ module MCP
4
+ # Manifest fingerprint changed mid-session. Client should re-boot.
5
+ class ContractDrift < Textus::Error
6
+ JSONRPC_CODE = -32_001
7
+
8
+ def initialize(message, details: {})
9
+ super("contract_drift", message, details: details)
10
+ end
11
+ end
12
+
13
+ # Audit cursor fell off the keep window. Client should re-boot and
14
+ # resume from the new latest_seq.
15
+ class CursorExpired < Textus::Error
16
+ JSONRPC_CODE = -32_002
17
+
18
+ def initialize(message, details: {})
19
+ super("cursor_expired", message, details: details)
20
+ end
21
+ end
22
+
23
+ # Tool execution failed (validation, authorization, IO). Wraps an
24
+ # underlying Textus::Error or generic StandardError.
25
+ class ToolError < Textus::Error
26
+ JSONRPC_CODE = -32_000
27
+
28
+ def initialize(message, details: {})
29
+ super("tool_error", message, details: details)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,145 @@
1
+ require "json"
2
+
3
+ module Textus
4
+ module Surfaces
5
+ module MCP
6
+ # Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
7
+ # message (NDJSON). Holds a single Session for the lifetime of stdin.
8
+ class Server
9
+ PROTOCOL_VERSION = "2024-11-05"
10
+ SERVER_INFO = { "name" => "textus", "version" => Textus::VERSION }.freeze
11
+ MAX_LINE_BYTES = 1_048_576 # 1 MB — protects against OOM from oversized tool calls
12
+
13
+ def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
14
+ @store = store
15
+ @stdin = stdin
16
+ @stdout = stdout
17
+ @role = role
18
+ @session = nil
19
+ end
20
+
21
+ def run
22
+ @stdin.each_line do |line|
23
+ line = line.strip
24
+ next if line.empty?
25
+
26
+ handle_line(line)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def handle_line(line)
33
+ if line.bytesize > MAX_LINE_BYTES
34
+ emit_error(nil, -32_700, "message too large (#{line.bytesize} bytes, limit #{MAX_LINE_BYTES})")
35
+ return
36
+ end
37
+ msg = JSON.parse(line)
38
+ rescue JSON::ParserError => e
39
+ emit_error(nil, -32_700, "parse error: #{e.message}")
40
+ else
41
+ dispatch(msg)
42
+ end
43
+
44
+ def dispatch(msg)
45
+ rid = msg["id"]
46
+ case msg["method"]
47
+ when "initialize" then handle_initialize(rid, msg["params"] || {})
48
+ when "tools/list" then handle_tools_list(rid)
49
+ when "tools/call" then handle_tools_call(rid, msg["params"] || {})
50
+ when "ping" then emit_result(rid, {})
51
+ when "shutdown" then emit_result(rid, nil)
52
+ when "notifications/initialized" then nil
53
+ else emit_error(rid, -32_601, "method not found: #{msg["method"]}")
54
+ end
55
+ end
56
+
57
+ def handle_initialize(rid, _params)
58
+ # The acting role IS the resolved connection role (ADR 0040): the MCP
59
+ # transport defaults to `agent`, which can write the queue, so its
60
+ # propose_lane resolves directly. If a connection's role cannot propose,
61
+ # propose_lane is nil and the `propose` tool reports that honestly.
62
+ propose_lane = @store.manifest.policy.propose_lane_for(@role)
63
+
64
+ @session = Session.new(
65
+ role: @role,
66
+ cursor: @store.audit_log.latest_seq,
67
+ propose_lane: propose_lane,
68
+ contract_etag: contract_etag,
69
+ )
70
+
71
+ # ADR 0075: announce the connection to connect-time hooks with the
72
+ # resolved role. Distinct from :store_loaded (fired at Store.new under
73
+ # the default role, before any connection's role is known).
74
+ @store.steps.publish(
75
+ :session_opened,
76
+ ctx: Step::Context.new(scope: @store.as(@role)),
77
+ role: @role,
78
+ cursor: @session.cursor,
79
+ )
80
+
81
+ emit_result(rid, {
82
+ "protocolVersion" => PROTOCOL_VERSION,
83
+ "serverInfo" => SERVER_INFO,
84
+ "capabilities" => { "tools" => {} },
85
+ })
86
+ end
87
+
88
+ def handle_tools_list(rid)
89
+ emit_result(rid, { "tools" => Catalog.tool_schemas })
90
+ end
91
+
92
+ def handle_tools_call(rid, params)
93
+ unless @session
94
+ emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
95
+ return
96
+ end
97
+
98
+ name = params["name"]
99
+ args = params["arguments"] || {}
100
+
101
+ # ADR 0083: the contract-drift guard gates mutating verbs — every MCP
102
+ # verb that is NOT a pure read (Write:: + the destructive Maintenance::
103
+ # verbs drain/data_mv/key_*_prefix). Reads and boot bypass it (a stale
104
+ # read returns on-disk truth; boot re-orients). Keying on read_verbs
105
+ # (not write_verbs) keeps the destructive Maintenance:: verbs gated.
106
+ @session.check_etag!(contract_etag) unless Catalog.read_verbs.include?(name)
107
+
108
+ result = Catalog.call(name, session: @session, store: @store, args: args)
109
+ @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
110
+ @session = @session.with(contract_etag: contract_etag) if name == "boot"
111
+
112
+ emit_result(rid, {
113
+ "content" => [{ "type" => "text", "text" => JSON.dump(result) }],
114
+ "isError" => false,
115
+ })
116
+ rescue ContractDrift => e
117
+ emit_error(rid, ContractDrift::JSONRPC_CODE, e.message)
118
+ rescue CursorExpired => e
119
+ emit_error(rid, CursorExpired::JSONRPC_CODE, e.message)
120
+ rescue ToolError => e
121
+ emit_error(rid, ToolError::JSONRPC_CODE, e.message)
122
+ rescue StandardError => e
123
+ emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
124
+ end
125
+
126
+ def contract_etag
127
+ Textus::Etag.for_contract(@store.root)
128
+ end
129
+
130
+ def emit_result(rid, result)
131
+ write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
132
+ end
133
+
134
+ def emit_error(rid, code, message)
135
+ write({ "jsonrpc" => "2.0", "id" => rid, "error" => { "code" => code, "message" => message } })
136
+ end
137
+
138
+ def write(obj)
139
+ @stdout.puts(JSON.dump(obj))
140
+ @stdout.flush
141
+ end
142
+ end
143
+ end
144
+ end
145
+ 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,38 @@
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
+
33
+ def hook_context
34
+ @hook_context ||= Textus::Step::Context.new(scope: self)
35
+ end
36
+ end
37
+ end
38
+ 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::Queue.new(root: container.root)
11
+ end
12
+
13
+ def tick
14
+ Textus::Background::Planner::Plan.seed(
15
+ container: @container,
16
+ queue: @queue,
17
+ role: Textus::Role::AUTOMATION,
18
+ )
19
+ @queue.reclaim(now: Textus::Ports::Clock.new.now)
20
+ Textus::Background::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.53.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end