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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Step
5
+ # Checks an artifact/store state and returns diagnostics. Replaces the
6
+ # :validate RPC. Receives only `caps:` (injected by the registry).
7
+ class Validate < Base
8
+ def self.required_kwargs = []
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ # A Step is a unit of user-extensible behaviour discovered by convention at
5
+ # .textus/steps/<kind>/<name>.rb. Five kinds: fetch (external acquisition),
6
+ # transform (combine/reshape into an artifact), validate (check an artifact),
7
+ # observe (react to a lifecycle event). Replaces the Textus.hook block queue.
8
+ module Step
9
+ end
10
+ end
data/lib/textus/store.rb CHANGED
@@ -45,7 +45,8 @@ module Textus
45
45
  def initialize(root)
46
46
  @container = build_container(File.expand_path(root))
47
47
  bootstrap_hooks
48
- events.publish(:store_loaded, ctx: Hooks::Context.new(scope: as(Role::DEFAULT)))
48
+ steps.publish(:store_loaded, ctx: Step::Context.for(container: @container,
49
+ call: Textus::Call.build(role: Role::DEFAULT)))
49
50
  end
50
51
 
51
52
  # Build an agent Session oriented at the current cursor/manifest — the
@@ -54,26 +55,24 @@ module Textus
54
55
  Textus::Session.new(
55
56
  role: role,
56
57
  cursor: audit_log.latest_seq,
57
- propose_zone: manifest.policy.propose_zone_for(role),
58
+ propose_lane: manifest.policy.propose_lane_for(role),
58
59
  contract_etag: Textus::Etag.for_contract(root),
59
60
  )
60
61
  end
61
62
 
62
- def as(role, dry_run: false, correlation_id: nil)
63
- RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
63
+ def gate
64
+ @container.gate
64
65
  end
65
66
 
66
- Textus::Dispatcher::VERBS.each_key do |verb|
67
- define_method(verb) do |*args, role: Role::DEFAULT, **kwargs|
68
- as(role).public_send(verb, *args, **kwargs)
69
- end
67
+ def as(role, dry_run: false, correlation_id: nil)
68
+ Textus::Surfaces::RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
70
69
  end
71
70
 
72
71
  private
73
72
 
74
73
  def build_container(root)
75
74
  manifest = Manifest.load(root)
76
- Container.new(
75
+ container = Container.new(
77
76
  root: root,
78
77
  manifest: manifest,
79
78
  schemas: Schemas.new(File.join(root, "schemas")),
@@ -83,16 +82,19 @@ module Textus
83
82
  max_size: manifest.data.audit_config[:max_size],
84
83
  keep: manifest.data.audit_config[:keep],
85
84
  ),
86
- events: Hooks::EventBus.new,
87
- rpc: Hooks::RpcRegistry.new,
85
+ steps: Textus::Step::RegistryStore.new,
86
+ gate: nil,
88
87
  )
88
+ gate = Textus::Gate.new(container)
89
+ container = container.with(gate: gate)
90
+ gate.instance_variable_set(:@container, container)
91
+ container
89
92
  end
90
93
 
91
94
  def bootstrap_hooks
92
- Ports::AuditSubscriber.new(audit_log).attach(events)
93
- Ports::ProduceOnWriteSubscriber.new(container).attach(events)
94
- Hooks::Builtin.register_all(events: events, rpc: rpc)
95
- Hooks::Loader.new(events: events, rpc: rpc).load_dir(File.join(root, "hooks"))
95
+ Ports::AuditSubscriber.new(audit_log).attach(steps)
96
+ Textus::Step::Builtin.register_all(steps)
97
+ Textus::Step::Loader.new(registry: steps).load_dir(File.join(root, "steps"))
96
98
  end
97
99
  end
98
100
  end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Group
5
+ class Data < Group
6
+ command_name "data"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Group
5
+ class Key < Group
6
+ command_name "key"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Group
5
+ class MCP < Group
6
+ command_name "mcp"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Group
5
+ class Rule < Group
6
+ command_name "rule"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Group
5
+ class Schema < Group
6
+ command_name "schema"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,50 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Group < Verb
5
+ class << self
6
+ # Subcommands are auto-derived: any Verb descendant whose
7
+ # `parent_group` is this group counts as a subcommand. Sorted
8
+ # alphabetically by command_name for stable help output.
9
+ def subcommands
10
+ Textus::Surfaces::CLI::Runner.install!
11
+ Verb.descendants
12
+ .select { |k| k.parent_group == self && k.command_name }
13
+ .sort_by(&:command_name)
14
+ .to_h { |k| [k.command_name, k] }
15
+ end
16
+
17
+ def needs_store?
18
+ # Delegate to the matched subcommand at parse time; default true.
19
+ true
20
+ end
21
+ end
22
+
23
+ def parse(argv)
24
+ subs = self.class.subcommands
25
+ subname = argv.shift
26
+ if subname.nil?
27
+ raise UsageError.new(
28
+ "#{self.class.command_name} requires a subcommand: #{subs.keys.join(", ")}",
29
+ )
30
+ end
31
+
32
+ @sub_klass = subs[subname]
33
+ unless @sub_klass
34
+ raise UsageError.new(
35
+ "unknown #{self.class.command_name} subcommand '#{subname}'. " \
36
+ "Valid: #{subs.keys.join(", ")}",
37
+ )
38
+ end
39
+
40
+ @sub = @sub_klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
41
+ @sub.parse(argv)
42
+ end
43
+
44
+ def call(store)
45
+ @sub.call(@sub_klass.needs_store? ? store : nil)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,236 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ # Generates CLI::Verb (and CLI::Group) subclasses from per-verb contracts,
5
+ # so the CLI surface is a projection of the contract — the operator-facing
6
+ # mirror of MCP::Catalog (ADR 0063).
7
+ module Runner
8
+ # Subclassable base for contract-projected verbs. Carries the verb's
9
+ # contract (class attr `spec`) and the generic dispatch, exposing one
10
+ # overridable seam, #invoke, that defaults to the generic projection.
11
+ # Escape-hatch verbs subclass this and override #invoke to add behavior
12
+ # (suggestions, --stdin, BuildLock, multi-dispatch) WITHOUT restating the
13
+ # verb name — `spec.verb` remains the single source of dispatch.
14
+ class Base < Verb
15
+ class << self
16
+ attr_accessor :spec
17
+
18
+ # ADR 0064: derive the CLI command name from the contract's cli_leaf
19
+ # when not set explicitly, so an escape-hatch class never restates its
20
+ # own name. The reconciliation spec proves command_name == cli_leaf for
21
+ # every such class, so this is an equivalence, not a behavior change.
22
+ def command_name(name = nil)
23
+ return super if name
24
+
25
+ super() || spec&.cli_leaf
26
+ end
27
+ end
28
+
29
+ def spec = self.class.spec
30
+
31
+ def call(store)
32
+ invoke(store)
33
+ end
34
+
35
+ # Default: pure contract projection. Override in subclasses for behavior.
36
+ def invoke(store)
37
+ Runner.dispatch(self, store, spec)
38
+ end
39
+
40
+ def flag_values(s = spec)
41
+ s.args.reject(&:positional).each_with_object({}) do |a, h|
42
+ raw = respond_to?(a.name) ? public_send(a.name) : nil
43
+ next if raw.nil?
44
+
45
+ h[a.name] = Runner.coerce(a, raw)
46
+ end
47
+ end
48
+ end
49
+
50
+ module_function
51
+
52
+ # Build a Command from the spec + parsed inputs, dispatch through Gate.
53
+ def dispatch(verb_instance, store, spec)
54
+ inputs = Textus::Contract::Binder.inputs_from_ordered(
55
+ spec, verb_instance.positional, verb_instance.flag_values(spec)
56
+ )
57
+ inputs = inputs.merge(Textus::Contract::Sources.from_stdin(spec, verb_instance.stdin)) if spec.cli_stdin
58
+ inputs = Textus::Contract::Sources.acquire(spec, inputs)
59
+ inputs = apply_cli_defaults(spec, inputs)
60
+ role = verb_instance.resolved_role(store)
61
+
62
+ invoke = lambda do |effective_inputs|
63
+ cmd = build_command(spec, effective_inputs, role)
64
+ store.gate.dispatch(cmd)
65
+ end
66
+
67
+ result = if spec.around
68
+ scope = store.as(role)
69
+ Textus::Contract::Around.with(spec.around, scope: scope, inputs: inputs, session: nil, &invoke)
70
+ else
71
+ invoke.call(inputs)
72
+ end
73
+ verb_instance.emit(shape(spec, result, inputs))
74
+ rescue Textus::Contract::MissingArgs => e
75
+ raise UsageError.new("#{spec.cli_path} requires #{e.missing.first.wire}")
76
+ end
77
+
78
+ def build_command(spec, inputs, role)
79
+ cmd_class = Textus::Gate::VERB_COMMAND.fetch(spec.verb) do
80
+ raise Textus::UsageError.new("no Command for verb: #{spec.verb}")
81
+ end
82
+ defaults = {}
83
+ spec.args.each do |a|
84
+ next if a.default == :__unset || inputs.key?(a.name)
85
+ next if a.default.nil? && a.required
86
+
87
+ defaults[a.name] = a.default
88
+ end
89
+ kwargs = defaults.merge(inputs)
90
+ kwargs[:role] = role if cmd_class.members.include?(:role) && !inputs.key?(:role) && spec.verb != :audit
91
+ check_missing_args!(spec, cmd_class, kwargs)
92
+
93
+ cmd_class.new(**kwargs.slice(*cmd_class.members))
94
+ end
95
+
96
+ def check_missing_args!(spec, cmd_class, kwargs)
97
+ params = cmd_class.instance_method(:initialize).parameters
98
+ required = if params == [[:rest]]
99
+ cmd_class.members
100
+ else
101
+ params.select { |t,| t == :keyreq }.map(&:last)
102
+ end
103
+ missing = required - kwargs.keys
104
+ return if missing.empty?
105
+
106
+ raise Textus::Contract::MissingArgs.new(spec, missing.map { |m| Struct.new(:wire, :name).new(m.to_s, m) })
107
+ end
108
+
109
+ # Fill CLI-specific defaults (cli_default:) for args the operator did not
110
+ # pass, where the CLI default diverges from the contract default the agent
111
+ # surfaces use — e.g. migrate/data_mv apply by default on the CLI but plan
112
+ # by default for agents (ADR 0068). The divergence is legible in the
113
+ # contract, not hidden in a hand class.
114
+ def apply_cli_defaults(spec, inputs)
115
+ spec.args.each_with_object(inputs.dup) do |a, h|
116
+ next if a.cli_default == :__unset || h.key?(a.name)
117
+
118
+ h[a.name] = a.cli_default
119
+ end
120
+ end
121
+
122
+ # Shape the use-case result for the CLI wire via the verb's :cli view
123
+ # (falling back to the default view). The view is called uniformly as
124
+ # (result, inputs); an inputs-aware view echoes an input such as the key
125
+ # (ADR 0067).
126
+ def shape(spec, result, inputs)
127
+ Textus::Contract::View.render(spec, :cli, result, inputs)
128
+ end
129
+
130
+ # The default the CLI flag is generated against — `cli_default:` when the
131
+ # operator-facing default diverges from the contract default the agent
132
+ # surfaces use, else the contract `default`. This drives boolean flag
133
+ # polarity so a verb that applies-by-default on the CLI but plans-by-default
134
+ # for agents (migrate, data_mv) gets a `--dry-run` flag, not `--no-dry-run`.
135
+ def effective_default(arg)
136
+ arg.cli_default == :__unset ? arg.default : arg.cli_default
137
+ end
138
+
139
+ def flagspec_for(arg)
140
+ wire = arg.wire.to_s.tr("_", "-")
141
+ if arg.type == :boolean
142
+ effective_default(arg) == true ? "--no-#{wire}" : "--#{wire}"
143
+ else
144
+ "--#{wire}=VALUE"
145
+ end
146
+ end
147
+
148
+ # NB: compare arg.type by equality, not `case`/`===` — `Integer === arg.type`
149
+ # is false when arg.type is the Integer *class* (it tests instance-of), so a
150
+ # `when Integer` branch would silently never coerce.
151
+ def coerce(arg, raw)
152
+ return effective_default(arg) != true if arg.type == :boolean
153
+ return Integer(raw) if arg.type == Integer
154
+
155
+ raw
156
+ end
157
+
158
+ def ensure_group(name)
159
+ const = name.split("_").map(&:capitalize).join
160
+ return Group.const_get(const, false) if Group.const_defined?(const, false)
161
+
162
+ g = Class.new(Group) { command_name name }
163
+ Group.const_set(const, g)
164
+ g
165
+ end
166
+
167
+ # Contract verbs whose CLI behavior is a genuine `< Runner::Base` override
168
+ # — behavior the generic projection cannot express (ADR 0068/0069):
169
+ # get — raises UnknownKey with resolver suggestions (a CLI-only
170
+ # affordance; the agent surface deliberately returns nil)
171
+ # put — reads the entry JSON from --stdin (ADR 0089: just stores bytes,
172
+ # no --fetch transform)
173
+ # (build removed in ADR 0087: materialization is system-pushed via drain/serve)
174
+ BEHAVIORAL_HATCHES = %i[get put].freeze
175
+
176
+ # Contract verbs whose CLI is a plain `< Verb` command, not a projection at
177
+ # all — composite reports assembled outside the contract.
178
+ # (boot removed: its contract carries surfaces :cli + the :lean arg, so the
179
+ # generic projection now generates it; the hand-authored CLI::Verb::Boot is
180
+ # deleted in ADR 0101.)
181
+ # (doctor retained: hand-authored to preserve --check=NAME flag spelling and
182
+ # the exit_code: res["ok"] ? 0 : 1 behavior — two things the generic
183
+ # projection cannot yet express; kept in ADR 0101 pending a future pass.)
184
+ # (fetch/fetch_all were removed in ADR 0079: Produce::Acquire::Intake is now internal,
185
+ # driven by the converge sweep (drain/serve) and hook run — ADR 0089 removed the
186
+ # read-through that once also drove it.)
187
+ NON_PROJECTED_CLI = %i[doctor].freeze
188
+
189
+ # The installer skips generation for either category.
190
+ HAND_AUTHORED_VERBS = (BEHAVIORAL_HATCHES + NON_PROJECTED_CLI).freeze
191
+
192
+ def hand_authored?(verb) = HAND_AUTHORED_VERBS.include?(verb)
193
+
194
+ def install!
195
+ @installed ||= {}
196
+ Textus::Gate::ROUTES.each_key do |cmd_class|
197
+ verb = Textus::Gate::VERB_COMMAND.key(cmd_class)
198
+ next unless verb
199
+
200
+ action_class = Textus::Gate::ROUTES[cmd_class].first
201
+ next unless action_class.respond_to?(:contract?) && action_class.contract?
202
+
203
+ spec = action_class.contract
204
+ next unless spec.cli?
205
+ next if hand_authored?(spec.verb)
206
+ next if @installed[spec.verb]
207
+
208
+ install_for(spec)
209
+ @installed[spec.verb] = true
210
+ end
211
+ end
212
+
213
+ def install_for(spec)
214
+ group = spec.cli_group ? ensure_group(spec.cli_group) : nil
215
+ leaf = spec.cli_leaf
216
+ non_positional = spec.args.reject(&:positional)
217
+
218
+ klass = Class.new(Base)
219
+ klass.spec = spec
220
+ klass.command_name leaf
221
+ klass.parent_group group if group
222
+ klass.option :as_flag, "--as=ROLE"
223
+ klass.option :use_stdin, "--stdin" if spec.cli_stdin
224
+ non_positional.each { |a| klass.option a.name, Runner.flagspec_for(a) }
225
+
226
+ # Anchor the anonymous class to a constant so descendants discovery is
227
+ # stable. Name it after the verb under a Generated namespace.
228
+ const_name = spec.verb.to_s.split("_").map(&:capitalize).join
229
+ gen = "Gen#{const_name}"
230
+ Verb.const_set(gen, klass) unless Verb.const_defined?(gen, false)
231
+ klass
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Verb
5
+ class Doctor < Verb
6
+ command_name "doctor"
7
+ option :checks, "--check=NAME"
8
+
9
+ def call(store)
10
+ cmd = Textus::Command::Doctor.new(
11
+ checks: checks&.split(",")&.map(&:strip),
12
+ role: resolved_role(store),
13
+ )
14
+ res = store.gate.dispatch(cmd)
15
+ emit(res, exit_code: res["ok"] ? 0 : 1)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Verb
5
+ class Get < Runner::Base
6
+ self.spec = Textus::Action::Get.contract
7
+ option :as_flag, "--as=ROLE"
8
+
9
+ def invoke(store)
10
+ key = positional.shift or raise UsageError.new("get requires a key")
11
+ cmd = Textus::Command::Get.new(key: key, role: resolved_role(store))
12
+ result = store.gate.dispatch(cmd)
13
+ raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
14
+
15
+ emit(result.to_h_for_wire)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Verb
5
+ class Init < Verb
6
+ command_name "init"
7
+
8
+ option :with_agent, "--with-agent"
9
+
10
+ def self.needs_store? = false
11
+
12
+ def call(_store)
13
+ target = File.join(@cwd, ".textus")
14
+ emit(Textus::Init.run(target, with_agent: !!with_agent))
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Verb
5
+ # Launches the MCP stdio server in the current process. Blocks on stdin;
6
+ # never returns until stdin closes. The connection acts as the `agent`
7
+ # role by default (ADR 0040): the agent channel proposes, it does not
8
+ # inherit the human's authority. Override per connection with --as, or
9
+ # TEXTUS_ROLE / .textus/role (same chain as every other verb).
10
+ class MCPServe < Verb
11
+ command_name "serve"
12
+ parent_group Group::MCP
13
+ option :as_flag, "--as=ROLE"
14
+
15
+ def call(store)
16
+ role = resolved_role(store, default: Textus::Role::AGENT)
17
+ Textus::Surfaces::MCP::Server.new(store: store, stdin: @stdin, stdout: @stdout, role: role).run
18
+ 0
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,30 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Verb
5
+ class Put < Runner::Base
6
+ self.spec = Textus::Action::Put.contract
7
+ option :as_flag, "--as=ROLE"
8
+ option :use_stdin, "--stdin"
9
+
10
+ def invoke(store)
11
+ key = positional.shift or raise UsageError.new("put requires a key")
12
+ raise UsageError.new("put requires --stdin in v1") unless use_stdin
13
+
14
+ payload = JSON.parse(@stdin.read)
15
+ cmd = Textus::Command::Put.new(
16
+ key: key,
17
+ meta: payload["_meta"] || {},
18
+ body: payload["body"] || "",
19
+ content: nil,
20
+ if_etag: payload["if_etag"],
21
+ role: resolved_role(store),
22
+ )
23
+ result = store.gate.dispatch(cmd)
24
+ emit(result.to_h_for_wire)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Verb
5
+ class SchemaDiff < Verb
6
+ command_name "diff"
7
+ parent_group Group::Schema
8
+
9
+ def call(store)
10
+ name = positional.shift or raise UsageError.new("schema diff NAME")
11
+ emit(Textus::Schema::Tools.diff(store, name: name))
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Verb
5
+ class SchemaInit < Verb
6
+ command_name "init"
7
+ parent_group Group::Schema
8
+
9
+ option :from_key, "--from=KEY"
10
+
11
+ def call(store)
12
+ name = positional.shift or raise UsageError.new("schema init NAME")
13
+ raise UsageError.new("schema init requires --from=KEY") unless from_key
14
+
15
+ emit(Textus::Schema::Tools.init(store, name: name, from: from_key))
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Verb
5
+ class SchemaMigrate < Verb
6
+ command_name "migrate"
7
+ parent_group Group::Schema
8
+
9
+ option :rename, "--rename=O:N"
10
+
11
+ def call(store)
12
+ name = positional.shift or raise UsageError.new("schema migrate NAME")
13
+ raise UsageError.new("schema migrate requires --rename=OLD:NEW") unless rename
14
+
15
+ emit(Textus::Schema::Tools.migrate(store, name: name, rename: rename))
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Verb
5
+ class Watch < Verb
6
+ command_name "watch"
7
+ option :as_flag, "--as=ROLE"
8
+ option :poll, "--poll=SECONDS"
9
+
10
+ def call(store)
11
+ watcher = Textus::Surfaces::Watcher.new(container: store.container)
12
+ watcher.run(poll: poll&.to_f)
13
+ 0
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end