textus 0.55.1 → 0.56.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 (329) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +175 -2768
  3. data/README.md +17 -18
  4. data/lib/textus/artifact.rb +8 -0
  5. data/lib/textus/errors.rb +2 -39
  6. data/lib/textus/human.rb +7 -0
  7. data/lib/textus/infra/adapters/concurrency_adapter.rb +15 -0
  8. data/lib/textus/infra/clock.rb +7 -0
  9. data/lib/textus/infra/database.rb +127 -0
  10. data/lib/textus/infra/file_system.rb +54 -0
  11. data/lib/textus/infra/locks.rb +50 -0
  12. data/lib/textus/infra/port/clock.rb +18 -0
  13. data/lib/textus/infra/port/publisher.rb +71 -0
  14. data/lib/textus/infra/port/sentinel.rb +25 -0
  15. data/lib/textus/infra/port/sentinel_store.rb +93 -0
  16. data/lib/textus/infra/store/audit_log.rb +42 -0
  17. data/lib/textus/infra/store/base.rb +25 -0
  18. data/lib/textus/infra/store/cursor_store.rb +27 -0
  19. data/lib/textus/infra/store/entry_index.rb +31 -0
  20. data/lib/textus/infra/store/envelope/meta.rb +63 -0
  21. data/lib/textus/infra/store/file_store.rb +23 -0
  22. data/lib/textus/infra/store/index/builder.rb +28 -0
  23. data/lib/textus/infra/store/index/lookup.rb +23 -0
  24. data/lib/textus/infra/store/link_store.rb +43 -0
  25. data/lib/textus/infra/store/workflow_queue.rb +54 -0
  26. data/lib/textus/infra/store.rb +29 -0
  27. data/lib/textus/ingest.rb +8 -0
  28. data/lib/textus/lanes/artifact/cache.rb +35 -0
  29. data/lib/textus/lanes/artifact/handlers.rb +29 -0
  30. data/lib/textus/lanes/artifact/lifecycle.rb +23 -0
  31. data/lib/textus/lanes/artifact/verbs.rb +18 -0
  32. data/lib/textus/lanes/ingest/dedup.rb +17 -0
  33. data/lib/textus/lanes/ingest/entry_types/asset.rb +45 -0
  34. data/lib/textus/lanes/ingest/entry_types/link.rb +16 -0
  35. data/lib/textus/lanes/ingest/entry_types/text.rb +17 -0
  36. data/lib/textus/lanes/ingest/entry_types.rb +25 -0
  37. data/lib/textus/lanes/ingest/handlers.rb +54 -0
  38. data/lib/textus/lanes/ingest/index_rebuilder.rb +13 -0
  39. data/lib/textus/lanes/ingest/key_builder.rb +33 -0
  40. data/lib/textus/lanes/ingest/resolver.rb +21 -0
  41. data/lib/textus/lanes/knowledge/freeform.rb +15 -0
  42. data/lib/textus/lanes/knowledge/handlers.rb +53 -0
  43. data/lib/textus/lanes/knowledge/runner.rb +45 -0
  44. data/lib/textus/lanes/knowledge/verbs.rb +16 -0
  45. data/lib/textus/lanes/scratchpad/handlers.rb +164 -0
  46. data/lib/textus/lanes/scratchpad/verbs.rb +73 -0
  47. data/lib/textus/{gate → protocol}/binder.rb +2 -10
  48. data/lib/textus/protocol/boot/catalog.rb +34 -0
  49. data/lib/textus/protocol/boot/context.rb +124 -0
  50. data/lib/textus/protocol/boot/protocol.rb +33 -0
  51. data/lib/textus/protocol/boot.rb +42 -0
  52. data/lib/textus/protocol/builder/domain.rb +17 -0
  53. data/lib/textus/protocol/builder/infrastructure.rb +17 -0
  54. data/lib/textus/protocol/builder/runtime.rb +46 -0
  55. data/lib/textus/protocol/builder.rb +11 -0
  56. data/lib/textus/protocol/container.rb +22 -0
  57. data/lib/textus/protocol/diff.rb +170 -0
  58. data/lib/textus/protocol/entry_constraint.rb +54 -0
  59. data/lib/textus/protocol/envelope.rb +70 -0
  60. data/lib/textus/protocol/format/base.rb +80 -0
  61. data/lib/textus/protocol/format/bash.rb +10 -0
  62. data/lib/textus/protocol/format/javascript.rb +10 -0
  63. data/lib/textus/protocol/format/json.rb +82 -0
  64. data/lib/textus/protocol/format/markdown.rb +53 -0
  65. data/lib/textus/protocol/format/python.rb +10 -0
  66. data/lib/textus/protocol/format/ruby.rb +10 -0
  67. data/lib/textus/protocol/format/script.rb +55 -0
  68. data/lib/textus/protocol/format/text.rb +48 -0
  69. data/lib/textus/protocol/format/yaml.rb +105 -0
  70. data/lib/textus/protocol/format.rb +73 -0
  71. data/lib/textus/protocol/freshness/ttl_evaluator.rb +61 -0
  72. data/lib/textus/protocol/gate.rb +61 -0
  73. data/lib/textus/protocol/handlers/command_context.rb +19 -0
  74. data/lib/textus/protocol/handlers/concern.rb +15 -0
  75. data/lib/textus/protocol/handlers/ops/audit_filter.rb +37 -0
  76. data/lib/textus/protocol/handlers/ops/bulk_planner.rb +38 -0
  77. data/lib/textus/protocol/handlers/ops/data_mv.rb +49 -0
  78. data/lib/textus/protocol/handlers/ops/git_blame.rb +65 -0
  79. data/lib/textus/protocol/handlers/ops/pulse_coordinator.rb +37 -0
  80. data/lib/textus/protocol/handlers/ops/source_expander.rb +41 -0
  81. data/lib/textus/protocol/handlers/query_context.rb +41 -0
  82. data/lib/textus/protocol/handlers/read.rb +67 -0
  83. data/lib/textus/protocol/handlers/system.rb +93 -0
  84. data/lib/textus/protocol/handlers/write.rb +57 -0
  85. data/lib/textus/protocol/init.rb +143 -0
  86. data/lib/textus/protocol/key/grammar.rb +35 -0
  87. data/lib/textus/protocol/key/matching.rb +23 -0
  88. data/lib/textus/protocol/key/path.rb +23 -0
  89. data/lib/textus/protocol/key.rb +25 -0
  90. data/lib/textus/protocol/lane.rb +12 -0
  91. data/lib/textus/{store/geometry.rb → protocol/layout.rb} +32 -22
  92. data/lib/textus/protocol/links/resolver.rb +33 -0
  93. data/lib/textus/protocol/links/uri_rewriter.rb +30 -0
  94. data/lib/textus/protocol/links.rb +10 -0
  95. data/lib/textus/protocol/manifest/data.rb +90 -0
  96. data/lib/textus/protocol/manifest/entry/base.rb +95 -0
  97. data/lib/textus/protocol/manifest/entry/leaf.rb +19 -0
  98. data/lib/textus/protocol/manifest/entry/nested.rb +19 -0
  99. data/lib/textus/protocol/manifest/entry/parser.rb +71 -0
  100. data/lib/textus/protocol/manifest/entry/publish.rb +209 -0
  101. data/lib/textus/protocol/manifest/entry.rb +10 -0
  102. data/lib/textus/protocol/manifest/policy/publish_target.rb +36 -0
  103. data/lib/textus/protocol/manifest/policy.rb +69 -0
  104. data/lib/textus/protocol/manifest/resolver.rb +156 -0
  105. data/lib/textus/protocol/manifest/schema/contract.rb +53 -0
  106. data/lib/textus/protocol/manifest/schema/validator.rb +52 -0
  107. data/lib/textus/protocol/manifest/schema.rb +40 -0
  108. data/lib/textus/protocol/manifest.rb +62 -0
  109. data/lib/textus/protocol/produce/render/context.rb +43 -0
  110. data/lib/textus/protocol/produce/render.rb +44 -0
  111. data/lib/textus/protocol/schema/registry.rb +44 -0
  112. data/lib/textus/protocol/schema/tools.rb +104 -0
  113. data/lib/textus/protocol/schema.rb +117 -0
  114. data/lib/textus/protocol/session/bridge.rb +11 -0
  115. data/lib/textus/protocol/session/context.rb +5 -0
  116. data/lib/textus/protocol/session.rb +87 -0
  117. data/lib/textus/protocol/store_engine/delete.rb +37 -0
  118. data/lib/textus/protocol/store_engine/move.rb +109 -0
  119. data/lib/textus/protocol/store_engine/pipeline.rb +61 -0
  120. data/lib/textus/protocol/store_engine/put.rb +104 -0
  121. data/lib/textus/protocol/store_engine.rb +86 -0
  122. data/lib/textus/protocol/v4.rb +25 -0
  123. data/lib/textus/protocol/verb_registry/arg_spec.rb +19 -0
  124. data/lib/textus/protocol/verb_registry/core_verbs.rb +164 -0
  125. data/lib/textus/protocol/verb_registry/verb_spec.rb +42 -0
  126. data/lib/textus/protocol/verb_registry.rb +42 -0
  127. data/lib/textus/surface/cli/group/{rule.rb → web.rb} +2 -2
  128. data/lib/textus/surface/cli/runner.rb +25 -25
  129. data/lib/textus/surface/cli/sources.rb +1 -3
  130. data/lib/textus/surface/cli/verb/get.rb +3 -6
  131. data/lib/textus/surface/cli/verb/init.rb +1 -1
  132. data/lib/textus/surface/cli/verb/put.rb +8 -6
  133. data/lib/textus/surface/cli/verb/web_serve.rb +22 -0
  134. data/lib/textus/surface/cli.rb +25 -6
  135. data/lib/textus/surface/dispatch.rb +17 -0
  136. data/lib/textus/surface/mcp/adapters/mcp_adapter.rb +28 -0
  137. data/lib/textus/surface/mcp/catalog.rb +26 -62
  138. data/lib/textus/surface/mcp/cursor_manager.rb +22 -0
  139. data/lib/textus/surface/mcp/drift_annotation.rb +19 -0
  140. data/lib/textus/surface/mcp/errors.rb +0 -10
  141. data/lib/textus/surface/mcp/server.rb +46 -46
  142. data/lib/textus/surface/watcher.rb +18 -12
  143. data/lib/textus/surface/web.rb +50 -0
  144. data/lib/textus/value/call.rb +1 -1
  145. data/lib/textus/{core → value}/duration.rb +1 -4
  146. data/lib/textus/value/etag.rb +7 -7
  147. data/lib/textus/value/payload.rb +7 -0
  148. data/lib/textus/version.rb +1 -1
  149. data/lib/textus/workflow/consumer.rb +39 -0
  150. data/lib/textus/workflow/dsl/lifecycle.rb +32 -0
  151. data/lib/textus/workflow/dsl/pattern_matcher.rb +20 -0
  152. data/lib/textus/workflow/dsl/step_builder.rb +56 -0
  153. data/lib/textus/workflow/dsl.rb +111 -21
  154. data/lib/textus/workflow/event_emitter.rb +21 -0
  155. data/lib/textus/workflow/publisher.rb +59 -0
  156. data/lib/textus/workflow/registry.rb +31 -8
  157. data/lib/textus/workflow/runner.rb +25 -61
  158. data/lib/textus/workflow/scheduler.rb +45 -0
  159. data/lib/textus/workflow/step_executor.rb +78 -0
  160. data/lib/textus/workflow/step_helpers.rb +102 -0
  161. data/lib/textus/workflow.rb +2 -5
  162. data/lib/textus.rb +16 -80
  163. metadata +167 -183
  164. data/SPEC.md +0 -908
  165. data/docs/architecture/README.md +0 -273
  166. data/docs/reference/conventions.md +0 -138
  167. data/lib/textus/action/accept.rb +0 -46
  168. data/lib/textus/action/audit.rb +0 -94
  169. data/lib/textus/action/base.rb +0 -42
  170. data/lib/textus/action/blame.rb +0 -79
  171. data/lib/textus/action/boot.rb +0 -15
  172. data/lib/textus/action/data_mv.rb +0 -58
  173. data/lib/textus/action/deps.rb +0 -19
  174. data/lib/textus/action/doctor.rb +0 -17
  175. data/lib/textus/action/drain.rb +0 -31
  176. data/lib/textus/action/enqueue.rb +0 -37
  177. data/lib/textus/action/get.rb +0 -34
  178. data/lib/textus/action/ingest.rb +0 -199
  179. data/lib/textus/action/jobs.rb +0 -27
  180. data/lib/textus/action/key_delete.rb +0 -26
  181. data/lib/textus/action/key_delete_prefix.rb +0 -35
  182. data/lib/textus/action/key_mv.rb +0 -122
  183. data/lib/textus/action/key_mv_prefix.rb +0 -48
  184. data/lib/textus/action/list.rb +0 -28
  185. data/lib/textus/action/propose.rb +0 -42
  186. data/lib/textus/action/published.rb +0 -22
  187. data/lib/textus/action/pulse.rb +0 -49
  188. data/lib/textus/action/put.rb +0 -38
  189. data/lib/textus/action/rdeps.rb +0 -24
  190. data/lib/textus/action/reject.rb +0 -28
  191. data/lib/textus/action/rule_explain.rb +0 -81
  192. data/lib/textus/action/rule_lint.rb +0 -62
  193. data/lib/textus/action/rule_list.rb +0 -38
  194. data/lib/textus/action/schema_envelope.rb +0 -22
  195. data/lib/textus/action/uid.rb +0 -19
  196. data/lib/textus/action/where.rb +0 -21
  197. data/lib/textus/boot.rb +0 -202
  198. data/lib/textus/contract/arg.rb +0 -10
  199. data/lib/textus/contract/dsl.rb +0 -88
  200. data/lib/textus/contract/spec.rb +0 -25
  201. data/lib/textus/contract.rb +0 -12
  202. data/lib/textus/core/freshness/evaluator.rb +0 -150
  203. data/lib/textus/core/freshness/verdict.rb +0 -34
  204. data/lib/textus/core/freshness.rb +0 -11
  205. data/lib/textus/core/retention/sweep.rb +0 -57
  206. data/lib/textus/core/retention.rb +0 -11
  207. data/lib/textus/core/sentinel.rb +0 -30
  208. data/lib/textus/doctor/check/audit_log.rb +0 -34
  209. data/lib/textus/doctor/check/generator_drift.rb +0 -29
  210. data/lib/textus/doctor/check/illegal_keys.rb +0 -60
  211. data/lib/textus/doctor/check/manifest_files.rb +0 -25
  212. data/lib/textus/doctor/check/notebook_sources.rb +0 -50
  213. data/lib/textus/doctor/check/orphaned_publish_targets.rb +0 -35
  214. data/lib/textus/doctor/check/proposal_targets.rb +0 -45
  215. data/lib/textus/doctor/check/protocol_version.rb +0 -47
  216. data/lib/textus/doctor/check/publish_tree_index_overlap.rb +0 -48
  217. data/lib/textus/doctor/check/raw_asset_paths.rb +0 -50
  218. data/lib/textus/doctor/check/rule_ambiguity.rb +0 -49
  219. data/lib/textus/doctor/check/schema_parse_error.rb +0 -28
  220. data/lib/textus/doctor/check/schema_violations.rb +0 -28
  221. data/lib/textus/doctor/check/schemas.rb +0 -26
  222. data/lib/textus/doctor/check/sentinels.rb +0 -60
  223. data/lib/textus/doctor/check/stale_reviewed_stamp.rb +0 -54
  224. data/lib/textus/doctor/check/templates.rb +0 -29
  225. data/lib/textus/doctor/check/unowned_schema_fields.rb +0 -40
  226. data/lib/textus/doctor/check.rb +0 -41
  227. data/lib/textus/doctor/validator.rb +0 -93
  228. data/lib/textus/doctor.rb +0 -54
  229. data/lib/textus/format/base.rb +0 -49
  230. data/lib/textus/format/json.rb +0 -102
  231. data/lib/textus/format/markdown.rb +0 -80
  232. data/lib/textus/format/shared.rb +0 -17
  233. data/lib/textus/format/text.rb +0 -55
  234. data/lib/textus/format/yaml.rb +0 -125
  235. data/lib/textus/format.rb +0 -46
  236. data/lib/textus/gate/auth.rb +0 -212
  237. data/lib/textus/gate.rb +0 -92
  238. data/lib/textus/init.rb +0 -157
  239. data/lib/textus/jobs.rb +0 -9
  240. data/lib/textus/key/distance.rb +0 -55
  241. data/lib/textus/key/grammar.rb +0 -33
  242. data/lib/textus/key/matching.rb +0 -24
  243. data/lib/textus/key/path.rb +0 -28
  244. data/lib/textus/manifest/capabilities.rb +0 -29
  245. data/lib/textus/manifest/data.rb +0 -102
  246. data/lib/textus/manifest/entry/base.rb +0 -105
  247. data/lib/textus/manifest/entry/ignore_matcher.rb +0 -46
  248. data/lib/textus/manifest/entry/leaf.rb +0 -17
  249. data/lib/textus/manifest/entry/nested.rb +0 -37
  250. data/lib/textus/manifest/entry/parser.rb +0 -99
  251. data/lib/textus/manifest/entry/produced.rb +0 -36
  252. data/lib/textus/manifest/entry/publish/mode.rb +0 -45
  253. data/lib/textus/manifest/entry/publish/none.rb +0 -14
  254. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +0 -73
  255. data/lib/textus/manifest/entry/publish/template.rb +0 -16
  256. data/lib/textus/manifest/entry/publish/to_paths.rb +0 -76
  257. data/lib/textus/manifest/entry/publish/tree.rb +0 -50
  258. data/lib/textus/manifest/entry/publish.rb +0 -45
  259. data/lib/textus/manifest/entry/validators/format_matrix.rb +0 -21
  260. data/lib/textus/manifest/entry/validators/ignore.rb +0 -28
  261. data/lib/textus/manifest/entry/validators/publish.rb +0 -30
  262. data/lib/textus/manifest/entry/validators.rb +0 -18
  263. data/lib/textus/manifest/entry.rb +0 -8
  264. data/lib/textus/manifest/policy/matcher.rb +0 -51
  265. data/lib/textus/manifest/policy/publish_target.rb +0 -34
  266. data/lib/textus/manifest/policy/react.rb +0 -30
  267. data/lib/textus/manifest/policy/retention.rb +0 -29
  268. data/lib/textus/manifest/policy/source.rb +0 -30
  269. data/lib/textus/manifest/policy.rb +0 -95
  270. data/lib/textus/manifest/resolver.rb +0 -118
  271. data/lib/textus/manifest/rules.rb +0 -86
  272. data/lib/textus/manifest/schema/contract.rb +0 -61
  273. data/lib/textus/manifest/schema/keys.rb +0 -90
  274. data/lib/textus/manifest/schema/semantics/cross_field.rb +0 -53
  275. data/lib/textus/manifest/schema/semantics/invariants.rb +0 -125
  276. data/lib/textus/manifest/schema/semantics/migration.rb +0 -83
  277. data/lib/textus/manifest/schema/semantics.rb +0 -27
  278. data/lib/textus/manifest/schema/validator.rb +0 -48
  279. data/lib/textus/manifest/schema/vocabulary.rb +0 -25
  280. data/lib/textus/manifest/schema.rb +0 -30
  281. data/lib/textus/manifest.rb +0 -65
  282. data/lib/textus/meta.rb +0 -54
  283. data/lib/textus/port/audit_log.rb +0 -268
  284. data/lib/textus/port/build_lock.rb +0 -64
  285. data/lib/textus/port/clock.rb +0 -10
  286. data/lib/textus/port/publisher.rb +0 -60
  287. data/lib/textus/port/sentinel_store.rb +0 -83
  288. data/lib/textus/port/storage/file_stat.rb +0 -19
  289. data/lib/textus/port/storage/file_store.rb +0 -26
  290. data/lib/textus/port/store.rb +0 -93
  291. data/lib/textus/port/watcher_lock.rb +0 -48
  292. data/lib/textus/produce/engine.rb +0 -50
  293. data/lib/textus/produce/render.rb +0 -20
  294. data/lib/textus/schema/tools.rb +0 -115
  295. data/lib/textus/schema.rb +0 -100
  296. data/lib/textus/schemas.rb +0 -54
  297. data/lib/textus/store/compositor.rb +0 -34
  298. data/lib/textus/store/container.rb +0 -43
  299. data/lib/textus/store/cursor.rb +0 -26
  300. data/lib/textus/store/envelope/reader.rb +0 -43
  301. data/lib/textus/store/envelope/writer.rb +0 -195
  302. data/lib/textus/store/index/builder.rb +0 -74
  303. data/lib/textus/store/index/lookup.rb +0 -60
  304. data/lib/textus/store/jobs/base.rb +0 -13
  305. data/lib/textus/store/jobs/index.rb +0 -15
  306. data/lib/textus/store/jobs/materialize.rb +0 -15
  307. data/lib/textus/store/jobs/plan.rb +0 -11
  308. data/lib/textus/store/jobs/planner.rb +0 -104
  309. data/lib/textus/store/jobs/queue.rb +0 -154
  310. data/lib/textus/store/jobs/registry.rb +0 -19
  311. data/lib/textus/store/jobs/retention.rb +0 -50
  312. data/lib/textus/store/jobs/sweep.rb +0 -21
  313. data/lib/textus/store/jobs/worker.rb +0 -64
  314. data/lib/textus/store/session.rb +0 -37
  315. data/lib/textus/store.rb +0 -99
  316. data/lib/textus/surface/cli/verb/doctor.rb +0 -19
  317. data/lib/textus/surface/cli/verb/schema_diff.rb +0 -17
  318. data/lib/textus/surface/cli/verb/schema_init.rb +0 -21
  319. data/lib/textus/surface/cli/verb/schema_migrate.rb +0 -21
  320. data/lib/textus/surface/mcp.rb +0 -8
  321. data/lib/textus/surface/projector.rb +0 -27
  322. data/lib/textus/surface/role_scope.rb +0 -34
  323. data/lib/textus/value/command.rb +0 -16
  324. data/lib/textus/value/envelope.rb +0 -89
  325. data/lib/textus/value/result.rb +0 -26
  326. data/lib/textus/workflow/collector.rb +0 -27
  327. data/lib/textus/workflow/context.rb +0 -5
  328. data/lib/textus/workflow/loader.rb +0 -17
  329. data/lib/textus/workflow/pattern.rb +0 -18
@@ -0,0 +1,104 @@
1
+ module Textus
2
+ module Protocol
3
+ class StoreEngine
4
+ module Put
5
+ module_function
6
+
7
+ def call(key, mentry:, path:, payload:, deps:, if_etag: nil, fmt: nil)
8
+ format = fmt || mentry.format
9
+
10
+ validate_input(key, payload, format, mentry, path)
11
+ validate_schema(payload, mentry, deps)
12
+
13
+ existing_env = read_existing(key, mentry, path, format, deps)
14
+ meta, content = inject_meta(payload, existing_env, format, deps)
15
+ bytes, eff_meta, eff_body, eff_content = serialize(payload, meta, content, path, format)
16
+
17
+ etag_before = Pipeline.check_etag(key, path, if_etag, deps.file_system)
18
+ deps.file_system.write(path, bytes)
19
+ envelope = Envelope.build(
20
+ key:, mentry:, path:,
21
+ meta: eff_meta, body: eff_body,
22
+ etag: Value::Etag.for_bytes(bytes),
23
+ content: eff_content
24
+ )
25
+
26
+ append_audit(key, etag_before, envelope, deps)
27
+ index_entry(key, mentry, format, eff_body, eff_content, deps)
28
+ Pipeline.emit_event(deps, event_type: "entry.written",
29
+ pending_key: key, emit_key: key,
30
+ emit_lane: mentry.lane)
31
+ envelope
32
+ end
33
+
34
+ def validate_input(key, payload, format, mentry, path)
35
+ EntryConstraint.validate_naming!(key, mentry)
36
+ fmt = Format.for(format)
37
+ fmt.enforce_name_match!(path, payload.meta)
38
+ fmt.validate_raw_entry!(
39
+ { "_meta" => payload.meta, "content" => payload.content },
40
+ mentry.lane,
41
+ )
42
+ end
43
+
44
+ def validate_schema(payload, mentry, deps)
45
+ EntryConstraint.validate_schema!(payload, mentry, schemas: deps.schemas)
46
+ end
47
+
48
+ def read_existing(key, mentry, path, format, deps)
49
+ return nil unless path && deps.file_system.exists?(path)
50
+
51
+ raw = deps.file_system.read(path)
52
+ parsed = Format.for(format).parse(raw, path:)
53
+ Envelope.build(
54
+ key:, mentry:, path:,
55
+ meta: parsed["_meta"] || {},
56
+ body: parsed["body"] || "",
57
+ content: parsed["content"],
58
+ etag: Value::Etag.for_file(path)
59
+ )
60
+ end
61
+
62
+ def inject_meta(payload, existing_env, format, deps)
63
+ existing_meta = existing_env ? existing_env.meta : {}
64
+ Infra::Store::Envelope::Meta.inject_all(
65
+ payload.meta || {}, payload.content, existing_meta,
66
+ format:,
67
+ etag_for: method(:etag_for_key).curry.call(deps)
68
+ )
69
+ end
70
+
71
+ def etag_for_key(deps, key)
72
+ path = deps.manifest.resolver.resolve(key).path
73
+ return nil unless deps.file_system.exists?(path)
74
+
75
+ Value::Etag.for_file(path)
76
+ rescue Textus::Error
77
+ nil
78
+ end
79
+
80
+ def serialize(payload, meta, content, path, format)
81
+ Format.for(format).serialize_for_put(
82
+ meta:, body: payload.body, content:, path:,
83
+ )
84
+ end
85
+
86
+ def append_audit(key, etag_before, envelope, deps)
87
+ deps.audit_log.append(
88
+ role: deps.call.role, verb: "put", key:,
89
+ etag_before:, etag_after: envelope.etag,
90
+ correlation_id: deps.call.correlation_id
91
+ )
92
+ end
93
+
94
+ def index_entry(key, mentry, format, eff_body, eff_content, deps)
95
+ content = eff_content ? eff_content.to_json : eff_body
96
+ deps.entry_index.record(
97
+ key:, lane: mentry.lane, format:,
98
+ schema: mentry.schema, content:
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,86 @@
1
+ module Textus
2
+ module Protocol
3
+ class StoreEngine
4
+ def initialize(file_system:, manifest:, layout:, store:, event_emitter: nil, schemas: nil)
5
+ @file_system = file_system
6
+ @manifest = manifest
7
+ @layout = layout
8
+ @store = store
9
+ @event_emitter = event_emitter
10
+ @schemas = schemas
11
+ end
12
+
13
+ def read(key:)
14
+ result = resolve(key:)
15
+ path = result.path
16
+ return nil unless path && @file_system.exists?(path)
17
+
18
+ raw = @file_system.read(path)
19
+ parsed = Format.for(result.entry.format).parse(raw, path:)
20
+ Envelope.build(key:, mentry: result.entry, path:,
21
+ meta: parsed["_meta"] || {},
22
+ body: parsed["body"] || "",
23
+ content: parsed["content"],
24
+ etag: Value::Etag.for_file(path))
25
+ end
26
+
27
+ def exists?(key:)
28
+ path = resolve(key:).path
29
+ path && @file_system.exists?(path)
30
+ end
31
+
32
+ def list(prefix: nil, lane: nil, q: nil, schema: nil)
33
+ if @store.entry_index && (q || schema)
34
+ rows = @store.entry_index.search(query: q, schema:, lane:, prefix:)
35
+ (rows || []).map { |r| { "key" => r["key"], "lane" => r["lane"] } }
36
+ else
37
+ rows = @manifest.resolver.enumerate(prefix:)
38
+ rows = rows.select { |r| r[:manifest_entry].lane == lane } if lane
39
+ rows.map { |r| { "key" => r[:key], "lane" => r[:manifest_entry].lane } }
40
+ end
41
+ end
42
+
43
+ def resolve(key:) = @manifest.resolver.resolve(key)
44
+ def mentry_for(key:)= resolve(key:).entry
45
+
46
+ def put(key:, meta:, body:, content:, call:, if_etag: nil, format: nil)
47
+ result = resolve(key:)
48
+ payload = Value::Payload.new(meta: meta || {}, body:, content:)
49
+ deps = build_deps(reader: nil, call:)
50
+
51
+ if format
52
+ fmt_mod = Textus::Protocol::Format.for(format)
53
+ path = result.path.sub(/\.[^.]+$/, "") + fmt_mod.nested_ext(result.remaining.last)
54
+ Put.call(key, mentry: result.entry, path:, payload:, if_etag:, deps:, fmt: format)
55
+ else
56
+ Put.call(key, mentry: result.entry, path: result.path, payload:, if_etag:, deps:)
57
+ end
58
+ end
59
+
60
+ def delete(key:, call:, if_etag: nil)
61
+ deps = Pipeline::DeleteDeps.new(
62
+ file_system: @file_system, manifest: @manifest, store: @store,
63
+ layout: @layout, event_emitter: @event_emitter, call:
64
+ )
65
+ Delete.call(key, if_etag:, deps:)
66
+ end
67
+
68
+ def move(from_key:, to_key:, call:, if_etag: nil, dry_run: false)
69
+ deps = Pipeline::MoveDeps.new(
70
+ file_system: @file_system, manifest: @manifest, store: @store,
71
+ layout: @layout, event_emitter: @event_emitter, reader: self, call:
72
+ )
73
+ Move.call(from_key:, to_key:, if_etag:, dry_run:, deps:)
74
+ end
75
+
76
+ private
77
+
78
+ def build_deps(reader:, call:)
79
+ Pipeline::WriteDeps.new(
80
+ file_system: @file_system, manifest: @manifest, store: @store,
81
+ layout: @layout, event_emitter: @event_emitter, schemas: @schemas, reader:, call:
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ module Protocol
3
+ module V4
4
+ LANES = {
5
+ "knowledge" => { writers: %w[human],
6
+ desc: "the maintained source of truth about the repo",
7
+ retention_ttl: nil },
8
+ "scratchpad" => { writers: %w[human agent],
9
+ desc: "the agent's own durable scratchpad (working memory)",
10
+ retention_ttl: nil },
11
+ "artifacts" => { writers: %w[automation],
12
+ desc: "machine-maintained computed outputs",
13
+ retention_ttl: 3600 * 24 * 30 },
14
+ "raw" => { writers: %w[human agent automation],
15
+ desc: "ingested external source material (write-once)",
16
+ retention_ttl: nil },
17
+ }.freeze
18
+
19
+ NAMING_PATTERNS = {
20
+ "sequential" => /\A\d{4}-[a-z0-9-]+\.\w+\z/,
21
+ "dated" => /\A\d{4}-\d{2}-\d{2}-[a-z0-9-]+\.\w+\z/,
22
+ }.freeze
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ module Protocol
3
+ module VerbRegistry
4
+ ArgSpec = Data.define(
5
+ :name, :type, :required, :positional,
6
+ :description, :wire_name, :default, :source, :coerce, :cli_default
7
+ ) do
8
+ def wire = wire_name || name
9
+
10
+ def self.arg(name:, type: String, required: false, positional: false,
11
+ description: nil, wire_name: nil,
12
+ default: nil, source: nil, coerce: nil, cli_default: nil)
13
+ new(name:, type:, required:, positional:,
14
+ description:, wire_name:, default:, source:, coerce:, cli_default:)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Protocol
5
+ module VerbRegistry
6
+ CoreVerbs = {
7
+ audit: VerbSpec.new(
8
+ :audit, "Query the audit log with optional filters.",
9
+ [ArgSpec.arg(name: :key, description: "filter to rows for this key"),
10
+ ArgSpec.arg(name: :lane, description: "filter to keys in this lane"),
11
+ ArgSpec.arg(name: :role, description: "filter to rows written under this role"),
12
+ ArgSpec.arg(name: :verb, description: "filter to rows for this verb"),
13
+ ArgSpec.arg(name: :since, description: "ISO-8601 timestamp or relative offset (e.g. 1h, 30m)"),
14
+ ArgSpec.arg(name: :seq_since, type: Integer, description: "return rows with seq > this cursor value"),
15
+ ArgSpec.arg(name: :correlation_id, description: "filter to rows with this correlation_id"),
16
+ ArgSpec.arg(name: :limit, type: Integer, description: "maximum number of rows to return")],
17
+ %i[cli mcp], :read
18
+ ),
19
+
20
+ blame: VerbSpec.new(
21
+ :blame, "Annotate audit rows with the git commit that introduced each file state.",
22
+ [ArgSpec.arg(name: :key, required: true, positional: true, description: "entry key to blame"),
23
+ ArgSpec.arg(name: :limit, type: Integer, description: "maximum number of audit rows to return")],
24
+ %i[cli mcp], :read
25
+ ),
26
+
27
+ boot: VerbSpec.new(
28
+ :boot, "Return the orientation contract: lanes, agent_quickstart, agent_protocol.",
29
+ [], %i[cli mcp], :read
30
+ ),
31
+
32
+ data_mv: VerbSpec.new(
33
+ :data_mv, "Rename a data lane — manifest + files. Refuses if destination exists.",
34
+ [ArgSpec.arg(name: :from, required: true, positional: true, description: "current data lane name"),
35
+ ArgSpec.arg(name: :to, required: true, positional: true, description: "new data lane name"),
36
+ ArgSpec.arg(name: :dry_run, type: :boolean, default: false,
37
+ description: "when true, returns planned zone move without applying")],
38
+ %i[cli mcp], :write
39
+ ),
40
+
41
+ deps: VerbSpec.new(
42
+ :deps, "List the keys a derived entry depends on.",
43
+ [ArgSpec.arg(name: :key, required: true, positional: true, description: "dotted key of the derived entry whose source keys you want")],
44
+ %i[cli mcp], :read
45
+ ),
46
+
47
+ drain: VerbSpec.new(
48
+ :drain, "Seed materialize + sweep jobs then drain the queue to empty.",
49
+ [], %i[cli mcp], :maintenance
50
+ ),
51
+
52
+ get: VerbSpec.new(
53
+ :get, "Read one entry — on-disk read with freshness verdict.",
54
+ [ArgSpec.arg(name: :key, required: true, positional: true, description: "dotted entry key to read, e.g. 'knowledge.project'")],
55
+ %i[cli mcp], :read
56
+ ),
57
+
58
+ graph: VerbSpec.new(
59
+ :graph, "Query the link graph — neighbors (direct links) or reachable (transitive, with optional depth limit).",
60
+ [ArgSpec.arg(name: :key, required: true, positional: true, description: "entry key to query"),
61
+ ArgSpec.arg(name: :depth, type: Integer, description: "max traversal depth for reachable query (unlimited when omitted)")],
62
+ %i[cli mcp], :read
63
+ ),
64
+
65
+ ingest: VerbSpec.new(
66
+ verb: :ingest, summary: "Capture external source material into the raw lane. Write-once.",
67
+ args: [ArgSpec.arg(name: :kind, required: true, positional: true, description: "source kind: url | file | asset"),
68
+ ArgSpec.arg(name: :slug, required: true, description: "human slug for the key suffix (kebab-case)"),
69
+ ArgSpec.arg(name: :url, description: "remote URL (required when kind=url)"),
70
+ ArgSpec.arg(name: :path, description: "local file path (required when kind=file or kind=asset)"),
71
+ ArgSpec.arg(name: :lane, description: "asset group subdirectory (required when kind=asset)"),
72
+ ArgSpec.arg(name: :label, description: "human label stored in source.label")],
73
+ surfaces: %i[cli mcp], category: :write, lane: :ingest
74
+ ),
75
+
76
+ key_delete: VerbSpec.new(
77
+ :key_delete, "Delete one entry by key. Returns {ok, key, deleted}.",
78
+ [ArgSpec.arg(name: :key, required: true, positional: true, description: "dotted entry key to delete"),
79
+ ArgSpec.arg(name: :if_etag, description: "optimistic-concurrency guard: etag you last read")],
80
+ %i[cli mcp], :write
81
+ ),
82
+
83
+ key_delete_prefix: VerbSpec.new(
84
+ :key_delete_prefix, "Bulk-delete every leaf key under prefix.",
85
+ [ArgSpec.arg(name: :prefix, required: true, positional: true, description: "every leaf key under this dotted prefix is deleted"),
86
+ ArgSpec.arg(name: :dry_run, type: :boolean, default: false,
87
+ description: "when true, returns keys that would be deleted without deleting")],
88
+ %i[cli mcp], :write
89
+ ),
90
+
91
+ key_mv: VerbSpec.new(
92
+ :key_mv, "Rename one entry (same zone + format). Refuses if target exists.",
93
+ [ArgSpec.arg(name: :old_key, required: true, positional: true, description: "current dotted key"),
94
+ ArgSpec.arg(name: :new_key, required: true, positional: true, description: "new dotted key (same zone and format)"),
95
+ ArgSpec.arg(name: :dry_run, type: :boolean, description: "when true, returns planned move without applying")],
96
+ %i[cli mcp], :write
97
+ ),
98
+
99
+ key_mv_prefix: VerbSpec.new(
100
+ :key_mv_prefix, "Bulk-rename every leaf key under from_prefix to to_prefix.",
101
+ [ArgSpec.arg(name: :from_prefix, required: true, positional: true, description: "dotted prefix whose leaf keys are renamed"),
102
+ ArgSpec.arg(name: :to_prefix, required: true, positional: true, description: "dotted prefix the keys are renamed to"),
103
+ ArgSpec.arg(name: :dry_run, type: :boolean, default: false, description: "when true, returns planned moves without applying")],
104
+ %i[cli mcp], :write
105
+ ),
106
+
107
+ list: VerbSpec.new(
108
+ :list, "List keys filtered by lane and/or prefix.",
109
+ [ArgSpec.arg(name: :prefix, description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.execution'"),
110
+ ArgSpec.arg(name: :lane, description: "restrict to one lane by name (see `boot` lanes)"),
111
+ ArgSpec.arg(name: :q, description: "full-text search query over entry content (FTS5)"),
112
+ ArgSpec.arg(name: :schema, description: "filter to entries whose schema matches this name")],
113
+ %i[cli mcp], :read
114
+ ),
115
+
116
+ pulse: VerbSpec.new(
117
+ :pulse, "Delta since cursor — changed entries, pending proposals, index freshness.",
118
+ [ArgSpec.arg(name: :since, type: Integer, description: "audit seq to diff from; defaults to the session cursor")],
119
+ %i[cli mcp], :read
120
+ ),
121
+
122
+ put: VerbSpec.new(
123
+ :put, "Create or update an entry. Schema-validated. Returns {uid, etag}.",
124
+ [ArgSpec.arg(name: :key, required: true, positional: true, description: "dotted entry key, e.g. 'knowledge.project'; must resolve to a zone the role may write"),
125
+ ArgSpec.arg(name: :meta, type: Hash, wire_name: :_meta,
126
+ description: "frontmatter; reads back as `_meta`. Schema-validated — call `schema KEY` first"),
127
+ ArgSpec.arg(name: :data, description: "unified payload — String for body (markdown/text), Hash for content (json/yaml)"),
128
+ ArgSpec.arg(name: :if_etag, description: "optimistic-concurrency guard; write rejected if entry changed since"),
129
+ ArgSpec.arg(name: :format, description: "override entry format for this write (e.g. script)")],
130
+ %i[cli mcp], :write
131
+ ),
132
+
133
+ rdeps: VerbSpec.new(
134
+ :rdeps, "List the derived entries that depend on a key (reverse deps).",
135
+ [ArgSpec.arg(name: :key, required: true, positional: true, description: "dotted key whose dependents you want")],
136
+ %i[cli mcp], :read
137
+ ),
138
+
139
+ schema_list: VerbSpec.new(
140
+ :schema_list, "List all registered schemas with their fields.",
141
+ [], %i[cli mcp], :read
142
+ ),
143
+
144
+ schema_show: VerbSpec.new(
145
+ :schema_show, "Return the schema (field shape) for an entry's family.",
146
+ [ArgSpec.arg(name: :key, required: true, positional: true, description: "any key in the family whose schema you want")],
147
+ %i[cli mcp], :read
148
+ ),
149
+
150
+ uid: VerbSpec.new(
151
+ :uid, "Return the stable UID of an entry without reading its body.",
152
+ [ArgSpec.arg(name: :key, required: true, positional: true, description: "entry key")],
153
+ %i[cli mcp], :read
154
+ ),
155
+
156
+ where: VerbSpec.new(
157
+ :where, "Resolve a key to its zone, owner, and path without reading the body.",
158
+ [ArgSpec.arg(name: :key, required: true, positional: true, description: "dotted key to locate")],
159
+ %i[cli mcp], :read
160
+ ),
161
+ }.freeze
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,42 @@
1
+ module Textus
2
+ module Protocol
3
+ module VerbRegistry
4
+ TYPE_MAP = {
5
+ String => "string", Integer => "integer", Hash => "object",
6
+ Array => "array", :boolean => "boolean"
7
+ }.freeze
8
+
9
+ SYSTEM_VERBS = %i[boot audit blame pulse drain workflow_spec].to_set.freeze
10
+
11
+ VerbSpec = Data.define(:verb, :summary, :args, :surfaces, :category) do
12
+ def initialize(verb:, summary:, args:, surfaces:, category:, lane: nil)
13
+ @lane = lane
14
+ super(verb:, summary:, args:, surfaces:, category:)
15
+ end
16
+
17
+ def lane = @lane
18
+ def system? = SYSTEM_VERBS.include?(verb)
19
+ def mcp? = surfaces.include?(:mcp)
20
+ def cli? = surfaces.include?(:cli)
21
+ def cli_path = verb.to_s.tr("_", " ")
22
+ def cli_words = cli_path.split
23
+ def cli_group = cli_words.size > 1 ? cli_words.first : nil
24
+ def cli_leaf = cli_words.last
25
+ def required_args = args.select(&:required)
26
+ def read? = category == :read
27
+ def write? = category == :write
28
+ def maintenance? = category == :maintenance
29
+
30
+ def input_schema
31
+ props = args.to_h do |a|
32
+ json_type = VerbRegistry::TYPE_MAP[a.type] || "string"
33
+ h = { "type" => json_type }
34
+ h["description"] = a.description if a.description
35
+ [a.wire.to_s, h]
36
+ end
37
+ { type: "object", properties: props, required: required_args.map { |a| a.wire.to_s } }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ module Textus
2
+ module Protocol
3
+ module VerbRegistry
4
+ require_relative "verb_registry/arg_spec"
5
+ require_relative "verb_registry/verb_spec"
6
+
7
+ VERBS = {}
8
+ POSITIONAL = {}
9
+
10
+ def self.register(spec)
11
+ VERBS[spec.verb] = spec
12
+ POSITIONAL[spec.verb] = spec.args.select(&:positional).map(&:name)
13
+ end
14
+
15
+ def self.for(verb) = VERBS[verb]
16
+ def self.positional_for(verb) = POSITIONAL[verb] || []
17
+ def self.summary_for(verb) = VERBS[verb]&.summary
18
+ def self.registered = VERBS.values
19
+
20
+ ENTRY_VERBS = %i[
21
+ get put list key_delete key_mv propose accept reject
22
+ audit blame where uid deps rdeps graph diff ingest loop
23
+ session_open session_close
24
+ ].freeze
25
+
26
+ OPS_VERBS = %i[
27
+ boot drain pulse data_mv
28
+ key_mv_prefix key_delete_prefix
29
+ ].freeze
30
+
31
+ VERB_DOMAIN = ENTRY_VERBS.to_h { |v| [v, :entry] }
32
+ .merge(OPS_VERBS.to_h { |v| [v, :ops] }).freeze
33
+
34
+ require_relative "verb_registry/core_verbs"
35
+ CoreVerbs.each_value { |spec| register(spec) }
36
+
37
+ require_relative "../lanes/knowledge/verbs"
38
+ require_relative "../lanes/scratchpad/verbs"
39
+ require_relative "../lanes/artifact/verbs"
40
+ end
41
+ end
42
+ end
@@ -2,8 +2,8 @@ module Textus
2
2
  module Surface
3
3
  class CLI
4
4
  class Group
5
- class Rule < Group
6
- command_name "rule"
5
+ class Web < Group
6
+ command_name "web"
7
7
  end
8
8
  end
9
9
  end
@@ -13,7 +13,7 @@ module Textus
13
13
  # verb name — `spec.verb` remains the single source of dispatch.
14
14
  class Base < Verb
15
15
  class << self
16
- attr_accessor :spec
16
+ attr_accessor :spec, :stdin_json
17
17
 
18
18
  # ADR 0064: derive the CLI command name from the contract's cli_leaf
19
19
  # when not set explicitly, so an escape-hatch class never restates its
@@ -50,17 +50,18 @@ module Textus
50
50
  module_function
51
51
 
52
52
  def dispatch(verb_instance, store, spec)
53
- inputs = Textus::Gate::Binder.inputs_from_ordered(
53
+ inputs = Textus::Protocol::Binder.inputs_from_ordered(
54
54
  spec, verb_instance.positional, verb_instance.flag_values(spec)
55
55
  )
56
- inputs = inputs.merge(Surface::CLI::Sources.from_stdin(spec, verb_instance.stdin)) if spec.cli_stdin
56
+ inputs = inputs.merge(Surface::CLI::Sources.from_stdin(spec, verb_instance.stdin)) if verb_instance.class.stdin_json
57
57
  inputs = Surface::CLI::Sources.acquire(spec, inputs)
58
58
  inputs = apply_cli_defaults(spec, inputs)
59
59
  role = verb_instance.resolved_role(store)
60
60
 
61
- result = store.gate.dispatch(spec:, inputs:, role:, surface: :cli)
61
+ s = store.with_role(role)
62
+ result = Textus::Surface::Dispatch.call(s, spec.verb, **inputs)
62
63
  verb_instance.emit(result)
63
- rescue Textus::Gate::MissingArgs => e
64
+ rescue Textus::Protocol::MissingArgs => e
64
65
  raise UsageError.new("#{spec.cli_path} requires #{e.missing.first.wire}")
65
66
  end
66
67
 
@@ -71,7 +72,7 @@ module Textus
71
72
  # contract, not hidden in a hand class.
72
73
  def apply_cli_defaults(spec, inputs)
73
74
  spec.args.each_with_object(inputs.dup) do |a, h|
74
- next if a.cli_default == :__unset || h.key?(a.name)
75
+ next if a.cli_default.nil? || h.key?(a.name)
75
76
 
76
77
  h[a.name] = a.cli_default
77
78
  end
@@ -83,7 +84,7 @@ module Textus
83
84
  # polarity so a verb that applies-by-default on the CLI but plans-by-default
84
85
  # for agents (migrate, data_mv) gets a `--dry-run` flag, not `--no-dry-run`.
85
86
  def effective_default(arg)
86
- arg.cli_default == :__unset ? arg.default : arg.cli_default
87
+ arg.cli_default.nil? ? arg.default : arg.cli_default
87
88
  end
88
89
 
89
90
  def flagspec_for(arg)
@@ -91,7 +92,7 @@ module Textus
91
92
  if arg.type == :boolean
92
93
  effective_default(arg) == true ? "--no-#{wire}" : "--#{wire}"
93
94
  else
94
- "--#{wire}=VALUE"
95
+ "--#{wire} VALUE"
95
96
  end
96
97
  end
97
98
 
@@ -126,16 +127,7 @@ module Textus
126
127
 
127
128
  # Contract verbs whose CLI is a plain `< Verb` command, not a projection at
128
129
  # all — composite reports assembled outside the contract.
129
- # (boot removed: its contract carries surfaces :cli + the :lean arg, so the
130
- # generic projection now generates it; the hand-authored CLI::Verb::Boot is
131
- # deleted in ADR 0101.)
132
- # (doctor retained: hand-authored to preserve --check=NAME flag spelling and
133
- # the exit_code: res["ok"] ? 0 : 1 behavior — two things the generic
134
- # projection cannot yet express; kept in ADR 0101 pending a future pass.)
135
- # (fetch/fetch_all were removed in ADR 0079: Produce::Acquire::Intake is now internal,
136
- # driven by the converge sweep (drain/serve) and hook run — ADR 0089 removed the
137
- # read-through that once also drove it.)
138
- NON_PROJECTED_CLI = %i[doctor].freeze
130
+ NON_PROJECTED_CLI = %i[].freeze
139
131
 
140
132
  # The installer skips generation for either category.
141
133
  HAND_AUTHORED_VERBS = (BEHAVIORAL_HATCHES + NON_PROJECTED_CLI).freeze
@@ -144,10 +136,7 @@ module Textus
144
136
 
145
137
  def install!
146
138
  @installed ||= {}
147
- Textus::Action::VERBS.each_value do |action_class|
148
- next unless action_class.respond_to?(:contract?) && action_class.contract?
149
-
150
- spec = action_class.contract
139
+ Textus::Protocol::VerbRegistry.registered.each do |spec|
151
140
  next unless spec.cli?
152
141
  next if hand_authored?(spec.verb)
153
142
  next if @installed[spec.verb]
@@ -157,17 +146,28 @@ module Textus
157
146
  end
158
147
  end
159
148
 
149
+ STDIN_VERBS = %i[propose].freeze
150
+ CLI_PATHS = {
151
+ uid: "key uid",
152
+ key_mv_prefix: "key mv-prefix",
153
+ key_delete_prefix: "key delete-prefix",
154
+ }.freeze
155
+
160
156
  def install_for(spec)
161
- group = spec.cli_group ? ensure_group(spec.cli_group) : nil
162
- leaf = spec.cli_leaf
157
+ cli_path = CLI_PATHS[spec.verb] || spec.cli_path
158
+ cli_words = cli_path.split
159
+ group_name = cli_words.size > 1 ? cli_words.first : nil
160
+ group = group_name ? ensure_group(group_name) : nil
161
+ leaf = cli_words.last
163
162
  non_positional = spec.args.reject(&:positional)
164
163
 
165
164
  klass = Class.new(Base)
166
165
  klass.spec = spec
166
+ klass.stdin_json = STDIN_VERBS.include?(spec.verb)
167
167
  klass.command_name leaf
168
168
  klass.parent_group group if group
169
169
  klass.option :as_flag, "--as=ROLE"
170
- klass.option :use_stdin, "--stdin" if spec.cli_stdin
170
+ klass.option :use_stdin, "--stdin" if klass.stdin_json
171
171
  non_positional.each { |a| klass.option a.name, Runner.flagspec_for(a) }
172
172
 
173
173
  # Anchor the anonymous class to a constant so descendants discovery is
@@ -25,10 +25,8 @@ module Textus
25
25
  # Parse a cli_stdin :json envelope into a by-name inputs hash, mapping
26
26
  # envelope keys (wire-names) to arg names.
27
27
  def from_stdin(spec, stream)
28
- return {} unless spec.cli_stdin == :json
29
-
30
28
  raw = stream.read.to_s
31
- return {} if raw.strip.empty? # no envelope piped -> required args surface as missing
29
+ return {} if raw.strip.empty?
32
30
 
33
31
  envelope = JSON.parse(raw)
34
32
  spec.args.each_with_object({}) do |a, h|
@@ -3,16 +3,13 @@ module Textus
3
3
  class CLI
4
4
  class Verb
5
5
  class Get < Runner::Base
6
- self.spec = Textus::Action::Get.contract
6
+ self.spec = Textus::Protocol::VerbRegistry.for(:get)
7
7
  option :as_flag, "--as=ROLE"
8
8
 
9
9
  def invoke(store)
10
10
  key = positional.shift or raise UsageError.new("get requires a key")
11
- spec = Textus::Action::Get.contract
12
- result = store.gate.dispatch(spec: spec, inputs: { key: key }, role: resolved_role(store), surface: :cli)
13
- raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
14
-
15
- emit(result)
11
+ s = store.with_role(resolved_role(store))
12
+ emit(s.get(key: key))
16
13
  end
17
14
  end
18
15
  end
@@ -11,7 +11,7 @@ module Textus
11
11
 
12
12
  def call(_store)
13
13
  target = File.join(@cwd, ".textus")
14
- emit(Textus::Init.run(target, with_agent: !!with_agent))
14
+ emit(Textus::Protocol::Init.run(target, with_agent: !!with_agent))
15
15
  end
16
16
  end
17
17
  end