textus 0.54.2 → 0.55.2

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 (242) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/README.md +17 -10
  4. data/SPEC.md +39 -11
  5. data/docs/architecture/README.md +23 -11
  6. data/docs/reference/conventions.md +5 -2
  7. data/exe/textus +1 -1
  8. data/lib/textus/boot.rb +64 -93
  9. data/lib/textus/dispatch/binder.rb +48 -0
  10. data/lib/textus/dispatch/contracts.rb +63 -0
  11. data/lib/textus/dispatch/handler_registry.rb +21 -0
  12. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  13. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  14. data/lib/textus/dispatch/middleware/base.rb +26 -0
  15. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  16. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  17. data/lib/textus/dispatch/pipeline.rb +35 -0
  18. data/lib/textus/doctor/check/audit_log.rb +2 -2
  19. data/lib/textus/doctor/check/generator_drift.rb +4 -4
  20. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  21. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  22. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  23. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  24. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  25. data/lib/textus/doctor/check/schemas.rb +1 -1
  26. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  27. data/lib/textus/doctor/check/sentinels.rb +4 -4
  28. data/lib/textus/doctor/check/templates.rb +1 -1
  29. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  30. data/lib/textus/doctor/check.rb +9 -10
  31. data/lib/textus/doctor.rb +2 -2
  32. data/lib/textus/errors.rb +8 -0
  33. data/lib/textus/format/base.rb +34 -10
  34. data/lib/textus/format/json.rb +5 -27
  35. data/lib/textus/format/markdown.rb +5 -27
  36. data/lib/textus/format/text.rb +5 -4
  37. data/lib/textus/format/yaml.rb +30 -27
  38. data/lib/textus/format.rb +22 -1
  39. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  40. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  41. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  42. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  43. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  44. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  45. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  46. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  47. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  48. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  49. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  50. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  51. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  52. data/lib/textus/handlers/read/get_entry.rb +68 -0
  53. data/lib/textus/handlers/read/list_keys.rb +36 -0
  54. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  55. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  56. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  57. data/lib/textus/handlers/read/where_entry.rb +18 -0
  58. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  59. data/lib/textus/handlers/write/data_mv.rb +55 -0
  60. data/lib/textus/handlers/write/delete_key.rb +17 -0
  61. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  62. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  63. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  64. data/lib/textus/handlers/write/move_key.rb +80 -0
  65. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  66. data/lib/textus/handlers/write/put_entry.rb +29 -0
  67. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  68. data/lib/textus/init.rb +5 -7
  69. data/lib/textus/jobs.rb +3 -9
  70. data/lib/textus/manifest/capabilities.rb +4 -4
  71. data/lib/textus/manifest/entry/base.rb +3 -3
  72. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  73. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
  74. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  75. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  76. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  77. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  78. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  79. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  80. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  81. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  82. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  83. data/lib/textus/manifest/policy/predicates.rb +54 -0
  84. data/lib/textus/manifest/policy/retention.rb +1 -1
  85. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  86. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  87. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  88. data/lib/textus/manifest/schema/semantics.rb +11 -216
  89. data/lib/textus/orchestration.rb +55 -0
  90. data/lib/textus/{ports → port}/audit_log.rb +46 -6
  91. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  92. data/lib/textus/{ports → port}/clock.rb +1 -1
  93. data/lib/textus/{ports → port}/publisher.rb +5 -5
  94. data/lib/textus/{core → port}/sentinel.rb +1 -6
  95. data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
  96. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  97. data/lib/textus/port/storage/file_store.rb +49 -0
  98. data/lib/textus/port/storage/interface.rb +17 -0
  99. data/lib/textus/port/store.rb +149 -0
  100. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  101. data/lib/textus/produce/engine.rb +1 -11
  102. data/lib/textus/produce/publisher.rb +21 -0
  103. data/lib/textus/schema/registry.rb +42 -0
  104. data/lib/textus/schema/tools.rb +7 -10
  105. data/lib/textus/store/container.rb +173 -0
  106. data/lib/textus/store/cursor.rb +26 -0
  107. data/lib/textus/store/entry/reader.rb +47 -0
  108. data/lib/textus/store/entry/writer.rb +219 -0
  109. data/lib/textus/store/envelope/meta.rb +61 -0
  110. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  111. data/lib/textus/store/freshness/evaluator.rb +20 -0
  112. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  113. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  114. data/lib/textus/store/freshness.rb +8 -0
  115. data/lib/textus/store/index/builder.rb +76 -0
  116. data/lib/textus/store/index/lookup.rb +60 -0
  117. data/lib/textus/store/jobs/base.rb +13 -0
  118. data/lib/textus/store/jobs/index.rb +15 -0
  119. data/lib/textus/store/jobs/materialize.rb +15 -0
  120. data/lib/textus/store/jobs/plan.rb +11 -0
  121. data/lib/textus/store/jobs/planner.rb +124 -0
  122. data/lib/textus/store/jobs/queue.rb +162 -0
  123. data/lib/textus/store/jobs/registry.rb +19 -0
  124. data/lib/textus/store/jobs/retention/base.rb +52 -0
  125. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  126. data/lib/textus/store/jobs/retention.rb +8 -0
  127. data/lib/textus/store/jobs/sweep.rb +21 -0
  128. data/lib/textus/store/jobs/worker.rb +64 -0
  129. data/lib/textus/store/layout.rb +97 -0
  130. data/lib/textus/store.rb +63 -32
  131. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  132. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  133. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  135. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  136. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  137. data/lib/textus/{surfaces → surface}/cli/runner.rb +14 -68
  138. data/lib/textus/surface/cli/sources.rb +41 -0
  139. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
  140. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
  141. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  142. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  143. data/lib/textus/surface/cli/verb/put.rb +27 -0
  144. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  145. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  146. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  147. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  148. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  149. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  150. data/lib/textus/surface/mcp/catalog.rb +58 -0
  151. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
  152. data/lib/textus/surface/mcp/projector.rb +20 -0
  153. data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
  154. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  155. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  156. data/lib/textus/value/call.rb +30 -0
  157. data/lib/textus/value/command.rb +16 -0
  158. data/lib/textus/{core → value}/duration.rb +1 -4
  159. data/lib/textus/value/envelope.rb +90 -0
  160. data/lib/textus/value/etag.rb +39 -0
  161. data/lib/textus/value/payload.rb +7 -0
  162. data/lib/textus/value/result.rb +46 -0
  163. data/lib/textus/value/role.rb +38 -0
  164. data/lib/textus/value/types.rb +13 -0
  165. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  166. data/lib/textus/verb_registry.rb +417 -0
  167. data/lib/textus/version.rb +1 -1
  168. data/lib/textus/workflow/loader.rb +4 -4
  169. data/lib/textus/workflow/runner.rb +11 -33
  170. data/lib/textus.rb +4 -69
  171. metadata +142 -105
  172. data/lib/textus/action/accept.rb +0 -44
  173. data/lib/textus/action/audit.rb +0 -131
  174. data/lib/textus/action/base.rb +0 -42
  175. data/lib/textus/action/blame.rb +0 -97
  176. data/lib/textus/action/boot.rb +0 -17
  177. data/lib/textus/action/data_mv.rb +0 -69
  178. data/lib/textus/action/deps.rb +0 -34
  179. data/lib/textus/action/doctor.rb +0 -24
  180. data/lib/textus/action/drain.rb +0 -39
  181. data/lib/textus/action/enqueue.rb +0 -53
  182. data/lib/textus/action/get.rb +0 -78
  183. data/lib/textus/action/ingest.rb +0 -203
  184. data/lib/textus/action/jobs.rb +0 -36
  185. data/lib/textus/action/key_delete.rb +0 -36
  186. data/lib/textus/action/key_delete_prefix.rb +0 -44
  187. data/lib/textus/action/key_mv.rb +0 -129
  188. data/lib/textus/action/key_mv_prefix.rb +0 -57
  189. data/lib/textus/action/list.rb +0 -42
  190. data/lib/textus/action/propose.rb +0 -52
  191. data/lib/textus/action/published.rb +0 -24
  192. data/lib/textus/action/pulse.rb +0 -56
  193. data/lib/textus/action/put.rb +0 -52
  194. data/lib/textus/action/rdeps.rb +0 -39
  195. data/lib/textus/action/reject.rb +0 -37
  196. data/lib/textus/action/rule_explain.rb +0 -91
  197. data/lib/textus/action/rule_lint.rb +0 -68
  198. data/lib/textus/action/rule_list.rb +0 -42
  199. data/lib/textus/action/schema_envelope.rb +0 -29
  200. data/lib/textus/action/uid.rb +0 -33
  201. data/lib/textus/action/where.rb +0 -36
  202. data/lib/textus/action/write_verb.rb +0 -44
  203. data/lib/textus/call.rb +0 -28
  204. data/lib/textus/command.rb +0 -41
  205. data/lib/textus/container.rb +0 -26
  206. data/lib/textus/contract/around.rb +0 -29
  207. data/lib/textus/contract/binder.rb +0 -88
  208. data/lib/textus/contract/resources/build_lock.rb +0 -17
  209. data/lib/textus/contract/resources/cursor.rb +0 -26
  210. data/lib/textus/contract/sources.rb +0 -39
  211. data/lib/textus/contract/view.rb +0 -15
  212. data/lib/textus/contract.rb +0 -174
  213. data/lib/textus/core/freshness/evaluator.rb +0 -150
  214. data/lib/textus/core/freshness.rb +0 -11
  215. data/lib/textus/core/retention/sweep.rb +0 -57
  216. data/lib/textus/core/retention.rb +0 -11
  217. data/lib/textus/cursor_store.rb +0 -24
  218. data/lib/textus/envelope/reader.rb +0 -46
  219. data/lib/textus/envelope/writer.rb +0 -209
  220. data/lib/textus/envelope.rb +0 -79
  221. data/lib/textus/etag.rb +0 -36
  222. data/lib/textus/gate/auth.rb +0 -227
  223. data/lib/textus/gate.rb +0 -116
  224. data/lib/textus/jobs/base.rb +0 -23
  225. data/lib/textus/jobs/materialize.rb +0 -20
  226. data/lib/textus/jobs/plan.rb +0 -9
  227. data/lib/textus/jobs/planner.rb +0 -101
  228. data/lib/textus/jobs/retention.rb +0 -48
  229. data/lib/textus/jobs/sweep.rb +0 -27
  230. data/lib/textus/jobs/worker.rb +0 -67
  231. data/lib/textus/layout.rb +0 -91
  232. data/lib/textus/ports/job_store/job.rb +0 -65
  233. data/lib/textus/ports/job_store.rb +0 -123
  234. data/lib/textus/ports/raw_index.rb +0 -61
  235. data/lib/textus/ports/storage/file_store.rb +0 -26
  236. data/lib/textus/role.rb +0 -36
  237. data/lib/textus/schemas.rb +0 -54
  238. data/lib/textus/session.rb +0 -35
  239. data/lib/textus/surfaces/cli/verb/put.rb +0 -30
  240. data/lib/textus/surfaces/mcp/catalog.rb +0 -111
  241. data/lib/textus/surfaces/role_scope.rb +0 -34
  242. data/lib/textus/types.rb +0 -15
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Verb
5
5
  class SchemaInit < Verb
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Verb
5
5
  class SchemaMigrate < Verb
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Verb
5
5
  class Watch < Verb
@@ -8,7 +8,7 @@ module Textus
8
8
  option :poll, "--poll=SECONDS"
9
9
 
10
10
  def call(store)
11
- watcher = Textus::Surfaces::Watcher.new(container: store.container)
11
+ watcher = Textus::Surface::Watcher.new(container: store.container)
12
12
  watcher.run(poll: poll&.to_f)
13
13
  0
14
14
  end
@@ -2,7 +2,7 @@ require "json"
2
2
  require "optparse"
3
3
 
4
4
  module Textus
5
- module Surfaces
5
+ module Surface
6
6
  class CLI
7
7
  # Subclasses must implement #call(store) and return an integer exit code.
8
8
  # Use #emit(obj) for normal JSON output (returns 0).
@@ -93,14 +93,9 @@ module Textus
93
93
  # Resolves the active role for this invocation. Honors the verb's
94
94
  # `--as` flag if declared, then TEXTUS_ROLE, then the project default.
95
95
  # Pass `default:` to override the fallback (e.g. MCPServe uses AGENT).
96
- def resolved_role(store, default: Role::DEFAULT)
96
+ def resolved_role(store, default: Value::Role::DEFAULT)
97
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)
98
+ Value::Role.resolve(flag: flag, env: ENV, root: store.root, default: default)
104
99
  end
105
100
 
106
101
  # The input stream — the source for a `cli_stdin` envelope (ADR 0068).
@@ -2,7 +2,7 @@ require "json"
2
2
  require "optparse"
3
3
 
4
4
  module Textus
5
- module Surfaces
5
+ module Surface
6
6
  class CLI
7
7
  # Auto-derived verb table. Every CLI::Verb (or Group) subclass that
8
8
  # declares `command_name "X"` and has no `parent_group` is a top-level
@@ -0,0 +1,58 @@
1
+ module Textus
2
+ module Surface
3
+ module MCP
4
+ module Catalog
5
+ PROJECTOR = Projector.new(view_key: :default).freeze
6
+
7
+ module_function
8
+
9
+ def specs
10
+ VerbRegistry.registered.select(&:mcp?)
11
+ end
12
+
13
+ def build_tools(mcp_server)
14
+ specs.map do |spec|
15
+ schema = spec.input_schema
16
+ schema = schema.reject { |k, v| k == :required && Array(v).empty? }
17
+ ::MCP::Tool.define(
18
+ name: spec.verb.to_s,
19
+ description: spec.summary,
20
+ input_schema: schema,
21
+ ) do |server_context:, **args|
22
+ mcp_server.dispatch(spec.verb, args, server_context)
23
+ end
24
+ end
25
+ end
26
+
27
+ def names
28
+ specs.map(&:verb).map(&:to_s)
29
+ end
30
+
31
+ def read_verbs
32
+ VerbRegistry.registered
33
+ .select { |s| s.read? && s.mcp? }
34
+ .map { |s| s.verb.to_s }
35
+ end
36
+
37
+ def write_verbs
38
+ VerbRegistry.registered
39
+ .select { |s| s.write? && s.mcp? }
40
+ .map { |s| s.verb.to_s }
41
+ end
42
+
43
+ def call(name, store:, args:)
44
+ spec = VerbRegistry.for(name.to_sym)
45
+ raise ToolError.new("unknown tool: #{name}") unless spec&.mcp?
46
+
47
+ PROJECTOR.dispatch(name, inputs: args, store:)
48
+ rescue Textus::Dispatch::MissingArgs => e
49
+ raise ToolError.new("#{name}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
50
+ rescue Textus::ContractDrift, Textus::CursorExpired
51
+ raise
52
+ rescue Textus::Error => e
53
+ raise ToolError.new("#{name}: #{e.message}")
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,16 +1,6 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  module MCP
4
- # Audit cursor fell off the keep window. Client should re-boot and
5
- # resume from the new latest_seq.
6
- class CursorExpired < Textus::Error
7
- JSONRPC_CODE = -32_002
8
-
9
- def initialize(message, details: {})
10
- super("cursor_expired", message, details: details)
11
- end
12
- end
13
-
14
4
  # Tool execution failed (validation, authorization, IO). Wraps an
15
5
  # underlying Textus::Error or generic StandardError.
16
6
  class ToolError < Textus::Error
@@ -0,0 +1,20 @@
1
+ module Textus
2
+ module Surface
3
+ module MCP
4
+ class Projector
5
+ def initialize(view_key: :default)
6
+ @view_key = view_key
7
+ end
8
+
9
+ def dispatch(verb_name, inputs:, store:)
10
+ spec = VerbRegistry.for(verb_name.to_sym)
11
+ raise Textus::UsageError.new("unknown verb: #{verb_name}") unless spec
12
+
13
+ bound = Textus::Dispatch::Binder.inputs_from_wire(spec, inputs)
14
+ result = store.public_send(verb_name.to_sym, **bound)
15
+ spec.view(@view_key).call(result, bound)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -3,26 +3,17 @@
3
3
  require "mcp"
4
4
 
5
5
  module Textus
6
- module Surfaces
6
+ module Surface
7
7
  module MCP
8
8
  # MCP stdio server backed by the official mcp gem. The SDK owns protocol
9
9
  # negotiation, tool dispatch, and JSON-RPC framing. This class owns the
10
10
  # textus Session lifecycle (built lazily on first tool call) and delegates
11
11
  # execution to Catalog.
12
12
  class Server
13
- def initialize(store:, role: Textus::Role::DEFAULT, stdin: $stdin, stdout: $stdout)
14
- @store = store
15
- @role = role
13
+ def initialize(store:, role: Textus::Value::Role::DEFAULT, stdin: $stdin, stdout: $stdout)
14
+ @store = store.with_role(role)
16
15
  @stdin = stdin
17
16
  @stdout = stdout
18
- # Session built eagerly so the contract_etag is captured at server start.
19
- # Changes to manifest/hooks/schemas after this point are detected as drift.
20
- @session = Textus::Session.new(
21
- role: @role,
22
- cursor: @store.audit_log.latest_seq,
23
- propose_lane: @store.manifest.policy.propose_lane_for(@role),
24
- contract_etag: contract_etag_now,
25
- )
26
17
 
27
18
  @sdk = ::MCP::Server.new(
28
19
  name: "textus",
@@ -34,7 +25,6 @@ module Textus
34
25
  @sdk.resources_read_handler { |params, server_context:| handle_resource_read(params[:uri].to_s, server_context) }
35
26
  end
36
27
 
37
- # Runs the stdio line loop; delegates each JSON line to the SDK.
38
28
  def run
39
29
  @stdin.each_line do |line|
40
30
  line = line.strip
@@ -48,32 +38,28 @@ module Textus
48
38
  end
49
39
  end
50
40
 
51
- # Called from every MCP::Tool handler block in Catalog.
52
- # The SDK parses JSON with symbolize_names: true — all nested keys are symbols.
53
- # Deep-stringify so Catalog.call receives the string-key format it expects.
54
41
  def dispatch(verb_name, args, _server_context)
55
42
  str_args = deep_stringify_keys(args)
56
- @session.check_etag!(contract_etag_now) unless Catalog.read_verbs.include?(verb_name.to_s)
57
- result = Catalog.call(verb_name.to_s, session: @session, store: @store, args: str_args)
58
- update_session_for(verb_name.to_s)
43
+ @store.check_etag!(contract_etag_now) unless Catalog.read_verbs.include?(verb_name.to_s)
44
+ result = Catalog.call(verb_name.to_s, store: @store, args: str_args)
45
+ @store = @store.advance_cursor(@store.audit_log.latest_seq) if verb_name == :pulse
46
+ @store = @store.with_role(@store.role) if verb_name == :boot
59
47
  ::MCP::Tool::Response.new([{ type: "text", text: JSON.dump(result) }])
60
48
  rescue Textus::ContractDrift => e
61
49
  raise_handler_error(e.message, Textus::ContractDrift::JSONRPC_CODE)
62
- rescue CursorExpired => e
63
- raise_handler_error(e.message, CursorExpired::JSONRPC_CODE)
64
- rescue Textus::Surfaces::MCP::ToolError => e
50
+ rescue Textus::CursorExpired => e
51
+ raise_handler_error(e.message, Textus::CursorExpired::JSONRPC_CODE)
52
+ rescue Textus::Surface::MCP::ToolError => e
65
53
  raise_handler_error(e.message, ToolError::JSONRPC_CODE)
66
54
  rescue StandardError => e
67
- raise_handler_error("internal: #{e.class}: #{e.message}", -32_603)
55
+ raise_handler_error("internal: #{e.message}", -32_603)
68
56
  end
69
57
 
70
58
  private
71
59
 
72
- def update_session_for(verb_name)
73
- @session = @session.advance_cursor(@store.audit_log.latest_seq) if verb_name == "pulse"
74
- @session = @session.with(contract_etag: contract_etag_now) if verb_name == "boot"
75
- end
76
-
60
+ # Snapshot at server init against the boot-time manifest. New produced entries
61
+ # added by a later reconcile are invisible until the server restarts — this is
62
+ # intentional: a ContractDrift will gate writes on any mid-session manifest change.
77
63
  def build_resources
78
64
  machine_lane = @store.manifest.policy.machine_lane
79
65
  return [] unless machine_lane
@@ -84,16 +70,18 @@ module Textus
84
70
  end
85
71
 
86
72
  def handle_resource_read(uri, _server_context)
87
- key = uri.delete_prefix("textus://").tr("/", ".")
88
- env = @store.as(@role).get(key)
73
+ key = uri.delete_prefix("textus://").tr("/", ".")
74
+ env = @store.get(key:)
89
75
  text = env.content.is_a?(Hash) ? JSON.dump(env.content) : (env.body || "").to_s
90
- mime = mime_for_format(@store.manifest.resolver.resolve(key).entry.format)
76
+ mime = mime_for_format(env.format)
91
77
  [{ uri: uri, mimeType: mime, text: text }]
92
78
  rescue Textus::Error => e
93
79
  raise_handler_error("resource read failed: #{e.message}", -32_603)
80
+ rescue StandardError => e
81
+ raise_handler_error("internal: #{e.message}", -32_603)
94
82
  end
95
83
 
96
- def contract_etag_now = Textus::Etag.for_contract(@store.root)
84
+ def contract_etag_now = Textus::Value::Etag.for_contract(@store.root)
97
85
 
98
86
  # The SDK parses JSON with symbolize_names:true, making all nested hash keys symbols.
99
87
  # Recursively stringify so Catalog.call receives string-keyed hashes throughout.
@@ -107,9 +95,10 @@ module Textus
107
95
 
108
96
  def mime_for_format(format)
109
97
  case format.to_s
110
- when "json" then "application/json"
111
- when "yaml" then "application/yaml"
112
- else "text/plain"
98
+ when "json" then "application/json"
99
+ when "yaml" then "application/yaml"
100
+ when "markdown" then "text/markdown"
101
+ else "text/plain"
113
102
  end
114
103
  end
115
104
 
@@ -1,7 +1,7 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
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.
4
+ # Wraps Textus::Surface::RoleScope as auto-derived tools. See ADR 0015.
5
5
  module MCP
6
6
  end
7
7
  end
@@ -3,26 +3,26 @@
3
3
  require "securerandom"
4
4
 
5
5
  module Textus
6
- module Surfaces
6
+ module Surface
7
7
  class Watcher
8
8
  def initialize(container:)
9
9
  @container = container
10
- @queue = Textus::Ports::JobStore.new(root: container.root)
11
10
  end
12
11
 
13
12
  def tick
14
- Textus::Jobs::Planner.seed(
13
+ queue = Textus::Store::Jobs::Queue.new(store: @container.job_store)
14
+ Textus::Store::Jobs::Planner.seed(
15
15
  container: @container,
16
- queue: @queue,
17
- role: Textus::Role::AUTOMATION,
16
+ queue: queue,
17
+ role: Textus::Value::Role::AUTOMATION,
18
18
  )
19
- @queue.reclaim(now: Textus::Ports::Clock.new.now)
20
- Textus::Jobs::Worker.for(container: @container, queue: @queue).drain
19
+ queue.reclaim(now: Textus::Port::Clock.new.now)
20
+ Textus::Store::Jobs::Worker.for(container: @container, queue: queue).drain
21
21
  end
22
22
 
23
23
  def run(poll: nil)
24
24
  interval = poll || @container.manifest.data.worker_config[:poll]
25
- lock = Textus::Ports::WatcherLock.new(@container.root)
25
+ lock = Textus::Port::WatcherLock.new(@container.root)
26
26
  lock.acquire
27
27
  begin
28
28
  loop do
@@ -0,0 +1,30 @@
1
+ require "securerandom"
2
+
3
+ module Textus
4
+ # Immutable per-invocation value. Carries who is acting (role), the
5
+ # request correlation id, the wall clock, and the dry_run flag — the
6
+ # bits Use Cases need that are not part of the Container.
7
+ module Value
8
+ Call = Data.define(:role, :correlation_id, :now, :dry_run) do
9
+ def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
10
+ new(
11
+ role: role.to_s,
12
+ correlation_id: correlation_id || SecureRandom.uuid,
13
+ now: now || Textus::Port::Clock.new.now,
14
+ dry_run: dry_run,
15
+ )
16
+ end
17
+
18
+ def dry_run? = dry_run
19
+
20
+ def with_role(new_role)
21
+ self.class.new(
22
+ role: new_role.to_s,
23
+ correlation_id: correlation_id,
24
+ now: now,
25
+ dry_run: dry_run,
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,16 @@
1
+ module Textus
2
+ module Value
3
+ Command = Data.define(:verb, :params, :role) do
4
+ def initialize(verb:, params:, role:)
5
+ super
6
+ params.freeze
7
+ freeze
8
+ end
9
+
10
+ def [](key) = params[key]
11
+ def key = params[:key]
12
+ def pending_key = params[:pending_key]
13
+ def dry_run = params[:dry_run]
14
+ end
15
+ end
16
+ end
@@ -1,8 +1,5 @@
1
1
  module Textus
2
- module Core
3
- # Parses a duration value into whole seconds. Accepts a bare integer (or
4
- # integer-string) of seconds, or `<n><unit>` with unit s/m/h/d. Returns
5
- # nil for nil or any unparseable value.
2
+ module Value
6
3
  module Duration
7
4
  UNIT_SECONDS = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
8
5
 
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+
5
+ module Textus
6
+ module Value
7
+ class Envelope < Dry::Struct
8
+ attribute :protocol, Types::String
9
+ attribute :key, Types::String
10
+ attribute :lane, Types::String
11
+ attribute :owner, Types::String.optional
12
+ attribute :path, Types::String
13
+ attribute :format, Types::FormatName
14
+ attribute :etag, Types::String
15
+ attribute :uid, Types::String.optional
16
+ attribute :sources, Types::Array.of(Types::Any).optional
17
+ attribute :schema_ref, Types::String.optional
18
+ attribute :meta, Types::Hash.default({}.freeze)
19
+ attribute :body, Types::String.optional
20
+ attribute :content, Types::Any.optional
21
+ attribute :freshness, Types::Any.optional
22
+
23
+ def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
24
+ new(
25
+ protocol: Textus::PROTOCOL,
26
+ key: key,
27
+ lane: mentry.lane,
28
+ owner: mentry.owner,
29
+ path: path,
30
+ format: mentry.format,
31
+ uid: extract_uid(meta),
32
+ sources: extract_sources(meta),
33
+ etag: etag,
34
+ schema_ref: mentry.schema,
35
+ meta: meta,
36
+ body: body,
37
+ content: content,
38
+ freshness: freshness,
39
+ )
40
+ end
41
+
42
+ def self.extract_uid(meta)
43
+ v = meta.is_a?(Hash) ? meta["uid"] : nil
44
+ v.is_a?(String) ? v : nil
45
+ end
46
+
47
+ def self.extract_sources(meta)
48
+ v = meta.is_a?(Hash) ? meta["sources"] : nil
49
+ return nil unless v.is_a?(Array) && !v.empty?
50
+
51
+ valid = v.select { |s| s.is_a?(String) || (s.is_a?(Hash) && s["key"].is_a?(String)) }
52
+ valid.empty? ? nil : valid
53
+ end
54
+
55
+ def with(**attrs) = self.class.new(to_h.merge(attrs))
56
+
57
+ def to_h_for_wire
58
+ h = {
59
+ "protocol" => protocol,
60
+ "key" => key,
61
+ "lane" => lane,
62
+ "owner" => owner,
63
+ "path" => path,
64
+ "format" => format,
65
+ "_meta" => meta,
66
+ "body" => body,
67
+ "etag" => etag,
68
+ "schema_ref" => schema_ref,
69
+ "uid" => uid,
70
+ }
71
+ h["sources"] = sources if sources
72
+ h["content"] = content unless content.nil?
73
+ freshness&.to_h_for_wire&.each { |k, v| h[k] = v }
74
+ h
75
+ end
76
+
77
+ def stale?
78
+ return false if freshness.nil?
79
+
80
+ freshness.stale == true
81
+ end
82
+
83
+ def fetching?
84
+ return false if freshness.nil?
85
+
86
+ freshness.fetching == true
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,39 @@
1
+ require "digest"
2
+
3
+ module Textus
4
+ module Value
5
+ module Etag
6
+ def self.for_bytes(bytes)
7
+ "sha256:#{Digest::SHA256.hexdigest(bytes)}"
8
+ end
9
+
10
+ def self.for_file(path)
11
+ for_bytes(File.binread(path))
12
+ end
13
+
14
+ # The fingerprint of everything an agent's boot orientation depends on:
15
+ # the manifest PLUS the executable contract — hooks and schemas. A
16
+ # mid-session edit to any of these makes the cached orientation stale, so
17
+ # the session must re-boot (ADR 0074). The composite is one digest over the
18
+ # sorted per-file listing, so it is order-stable.
19
+ def self.for_contract(root)
20
+ listing = contract_files(root).map do |path|
21
+ rel = path.delete_prefix(root).delete_prefix("/")
22
+ "#{rel}:#{for_file(path)}"
23
+ end.join("\n")
24
+ for_bytes(listing)
25
+ end
26
+
27
+ # manifest.yaml, then every hook and schema file. Dir.glob already returns
28
+ # sorted paths (Ruby 3.0+), keeping the digest independent of FS order.
29
+ def self.contract_files(root)
30
+ geom = Textus::Store::Layout.new(root)
31
+ [
32
+ geom.manifest_path,
33
+ *Dir.glob(File.join(geom.hooks_dir, "**", "*.rb")),
34
+ *Dir.glob(geom.schemas_glob).select { |f| File.file?(f) },
35
+ ]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Value
5
+ Payload = Data.define(:meta, :body, :content)
6
+ end
7
+ end
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ module Value
3
+ # rubocop:disable Lint/ConstantDefinitionInBlock
4
+ Result = Data.define(:ok, :value, :error) do
5
+ def self.success(value) = new(ok: true, value: value, error: nil)
6
+
7
+ def self.failure(code, message, details: {})
8
+ new(ok: false, value: nil, error: { code: code, message: message, details: details })
9
+ end
10
+
11
+ def self.extract(result)
12
+ case result
13
+ when self
14
+ if result.success?
15
+ result.value
16
+ else
17
+ err = result.error
18
+ raise Textus::ActionError.new(err[:code] || :error, err[:message] || "action failed", details: err[:details] || {})
19
+ end
20
+ else
21
+ result
22
+ end
23
+ end
24
+
25
+ def success? = ok
26
+ def failure? = !ok
27
+
28
+ def unwrap
29
+ raise Result::UnwrapError.new(error[:code], error[:message], details: error[:details]) unless ok
30
+
31
+ value
32
+ end
33
+
34
+ class UnwrapError < StandardError
35
+ attr_reader :code, :details
36
+
37
+ def initialize(code, message, details: {})
38
+ super(message)
39
+ @code = code
40
+ @details = details
41
+ end
42
+ end
43
+ end
44
+ # rubocop:enable Lint/ConstantDefinitionInBlock
45
+ end
46
+ end
@@ -0,0 +1,38 @@
1
+ module Textus
2
+ module Value
3
+ module Role
4
+ # The three role archetypes, each string sourced exactly once: human curates
5
+ # canon, agent proposes, automation converges the machine-maintained lanes
6
+ # (refresh + materialize) (explanation/concepts.md).
7
+ # Reference these constants instead of bare literals (ADR 0044).
8
+ HUMAN = "human".freeze
9
+ AGENT = "agent".freeze
10
+ AUTOMATION = "automation".freeze
11
+
12
+ # The closed set of legal role names (ADR 0045), built FROM the archetypes
13
+ # above so it stays the single source of truth — a manifest declaring any
14
+ # other name is rejected at load, and DEFAULT ∈ NAMES holds structurally.
15
+ # Capabilities (`can:`) remain freely tunable per role.
16
+ NAMES = [HUMAN, AGENT, AUTOMATION].freeze
17
+
18
+ # Default acting identity (ADR 0040): a *choice* over the vocabulary, not a
19
+ # new name. CLI callers act as the human; an agent over stdio proposes and
20
+ # does not inherit the human's authority (it defaults to AGENT per transport).
21
+ DEFAULT = HUMAN
22
+
23
+ def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
24
+ candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
25
+ raise InvalidRole.new(candidate) unless NAMES.include?(candidate)
26
+
27
+ candidate
28
+ end
29
+
30
+ def self.read_file(root)
31
+ path = File.join(root, "role")
32
+ return nil unless File.exist?(path)
33
+
34
+ File.read(path).strip.then { |s| s.empty? ? nil : s }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,13 @@
1
+ module Textus
2
+ module Value
3
+ module Types
4
+ include Dry.Types()
5
+
6
+ RoleName = Types::String.constrained(included_in: Textus::Value::Role::NAMES)
7
+ Cursor = Types::Integer.constrained(gteq: 0)
8
+ FormatName = Types::String.constrained(
9
+ included_in: %w[markdown json yaml text], # must match Format::STRATEGIES.keys
10
+ )
11
+ end
12
+ end
13
+ end
@@ -4,15 +4,17 @@ module Textus
4
4
  # A Textus UID: 16 lowercase hex chars (SecureRandom.hex(8)). Not a UUID —
5
5
  # short on purpose. Random enough for collision-never-in-practice within a
6
6
  # single store.
7
- module Uid
8
- module_function
7
+ module Value
8
+ module Uid
9
+ module_function
9
10
 
10
- def mint
11
- SecureRandom.hex(8)
12
- end
11
+ def mint
12
+ SecureRandom.hex(8)
13
+ end
13
14
 
14
- def valid?(str)
15
- str.is_a?(String) && str.match?(/\A[0-9a-f]{16}\z/)
15
+ def valid?(str)
16
+ str.is_a?(String) && str.match?(/\A[0-9a-f]{16}\z/)
17
+ end
16
18
  end
17
19
  end
18
20
  end