textus 0.54.2 → 0.55.1

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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +8 -1
  4. data/SPEC.md +27 -0
  5. data/docs/architecture/README.md +20 -8
  6. data/docs/reference/conventions.md +1 -1
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +23 -21
  9. data/lib/textus/action/audit.rb +24 -61
  10. data/lib/textus/action/base.rb +9 -9
  11. data/lib/textus/action/blame.rb +18 -36
  12. data/lib/textus/action/boot.rb +2 -4
  13. data/lib/textus/action/data_mv.rb +20 -31
  14. data/lib/textus/action/deps.rb +3 -18
  15. data/lib/textus/action/doctor.rb +2 -9
  16. data/lib/textus/action/drain.rb +11 -19
  17. data/lib/textus/action/enqueue.rb +14 -30
  18. data/lib/textus/action/get.rb +12 -56
  19. data/lib/textus/action/ingest.rb +74 -78
  20. data/lib/textus/action/jobs.rb +6 -15
  21. data/lib/textus/action/key_delete.rb +6 -16
  22. data/lib/textus/action/key_delete_prefix.rb +8 -17
  23. data/lib/textus/action/key_mv.rb +54 -61
  24. data/lib/textus/action/key_mv_prefix.rb +13 -22
  25. data/lib/textus/action/list.rb +7 -21
  26. data/lib/textus/action/propose.rb +16 -26
  27. data/lib/textus/action/published.rb +3 -5
  28. data/lib/textus/action/pulse.rb +19 -26
  29. data/lib/textus/action/put.rb +15 -29
  30. data/lib/textus/action/rdeps.rb +3 -18
  31. data/lib/textus/action/reject.rb +12 -21
  32. data/lib/textus/action/rule_explain.rb +12 -22
  33. data/lib/textus/action/rule_lint.rb +10 -16
  34. data/lib/textus/action/rule_list.rb +5 -9
  35. data/lib/textus/action/schema_envelope.rb +3 -10
  36. data/lib/textus/action/uid.rb +3 -17
  37. data/lib/textus/action/where.rb +3 -18
  38. data/lib/textus/boot.rb +7 -15
  39. data/lib/textus/contract/arg.rb +10 -0
  40. data/lib/textus/contract/dsl.rb +88 -0
  41. data/lib/textus/contract/spec.rb +25 -0
  42. data/lib/textus/contract.rb +0 -162
  43. data/lib/textus/doctor/check/audit_log.rb +2 -2
  44. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  45. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  46. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  47. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  48. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  49. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  50. data/lib/textus/doctor/check/schemas.rb +1 -1
  51. data/lib/textus/doctor/check/sentinels.rb +4 -4
  52. data/lib/textus/doctor/check/templates.rb +1 -1
  53. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  54. data/lib/textus/doctor/check.rb +4 -7
  55. data/lib/textus/doctor.rb +1 -1
  56. data/lib/textus/errors.rb +6 -0
  57. data/lib/textus/format/base.rb +0 -4
  58. data/lib/textus/format/json.rb +5 -6
  59. data/lib/textus/format/markdown.rb +5 -6
  60. data/lib/textus/format/shared.rb +17 -0
  61. data/lib/textus/format/text.rb +5 -4
  62. data/lib/textus/format/yaml.rb +30 -6
  63. data/lib/textus/format.rb +6 -0
  64. data/lib/textus/gate/auth.rb +2 -17
  65. data/lib/textus/gate/binder.rb +50 -0
  66. data/lib/textus/gate.rb +64 -88
  67. data/lib/textus/init.rb +2 -4
  68. data/lib/textus/jobs.rb +3 -9
  69. data/lib/textus/manifest/capabilities.rb +3 -3
  70. data/lib/textus/manifest/entry/base.rb +1 -1
  71. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  73. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  74. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  75. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  76. data/lib/textus/manifest/schema/semantics.rb +11 -216
  77. data/lib/textus/meta.rb +54 -0
  78. data/lib/textus/{ports → port}/audit_log.rb +44 -4
  79. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  80. data/lib/textus/{ports → port}/clock.rb +1 -1
  81. data/lib/textus/{ports → port}/publisher.rb +5 -5
  82. data/lib/textus/{ports → port}/sentinel_store.rb +3 -3
  83. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  84. data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
  85. data/lib/textus/port/store.rb +93 -0
  86. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  87. data/lib/textus/produce/engine.rb +1 -1
  88. data/lib/textus/schema/tools.rb +11 -7
  89. data/lib/textus/store/compositor.rb +34 -0
  90. data/lib/textus/store/container.rb +43 -0
  91. data/lib/textus/store/cursor.rb +26 -0
  92. data/lib/textus/store/envelope/reader.rb +43 -0
  93. data/lib/textus/store/envelope/writer.rb +195 -0
  94. data/lib/textus/store/geometry.rb +81 -0
  95. data/lib/textus/store/index/builder.rb +74 -0
  96. data/lib/textus/store/index/lookup.rb +60 -0
  97. data/lib/textus/store/jobs/base.rb +13 -0
  98. data/lib/textus/store/jobs/index.rb +15 -0
  99. data/lib/textus/store/jobs/materialize.rb +15 -0
  100. data/lib/textus/store/jobs/plan.rb +11 -0
  101. data/lib/textus/store/jobs/planner.rb +104 -0
  102. data/lib/textus/store/jobs/queue.rb +154 -0
  103. data/lib/textus/store/jobs/registry.rb +19 -0
  104. data/lib/textus/store/jobs/retention.rb +50 -0
  105. data/lib/textus/store/jobs/sweep.rb +21 -0
  106. data/lib/textus/store/jobs/worker.rb +64 -0
  107. data/lib/textus/store/session.rb +37 -0
  108. data/lib/textus/store.rb +21 -13
  109. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  110. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  111. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  112. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  113. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  114. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  115. data/lib/textus/{surfaces → surface}/cli/runner.rb +10 -63
  116. data/lib/textus/surface/cli/sources.rb +41 -0
  117. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
  118. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
  119. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  120. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  121. data/lib/textus/{surfaces → surface}/cli/verb/put.rb +6 -11
  122. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  123. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  124. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  125. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  126. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  127. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  128. data/lib/textus/{surfaces → surface}/mcp/catalog.rb +9 -26
  129. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
  130. data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
  131. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  132. data/lib/textus/surface/projector.rb +27 -0
  133. data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  135. data/lib/textus/value/call.rb +30 -0
  136. data/lib/textus/value/command.rb +16 -0
  137. data/lib/textus/value/envelope.rb +89 -0
  138. data/lib/textus/value/etag.rb +39 -0
  139. data/lib/textus/value/result.rb +26 -0
  140. data/lib/textus/value/role.rb +38 -0
  141. data/lib/textus/value/types.rb +13 -0
  142. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  143. data/lib/textus/version.rb +1 -1
  144. data/lib/textus/workflow/loader.rb +4 -4
  145. data/lib/textus/workflow/runner.rb +4 -18
  146. data/lib/textus.rb +9 -10
  147. metadata +100 -63
  148. data/lib/textus/action/write_verb.rb +0 -44
  149. data/lib/textus/call.rb +0 -28
  150. data/lib/textus/command.rb +0 -41
  151. data/lib/textus/container.rb +0 -26
  152. data/lib/textus/contract/around.rb +0 -29
  153. data/lib/textus/contract/binder.rb +0 -88
  154. data/lib/textus/contract/resources/build_lock.rb +0 -17
  155. data/lib/textus/contract/resources/cursor.rb +0 -26
  156. data/lib/textus/contract/sources.rb +0 -39
  157. data/lib/textus/contract/view.rb +0 -15
  158. data/lib/textus/cursor_store.rb +0 -24
  159. data/lib/textus/envelope/reader.rb +0 -46
  160. data/lib/textus/envelope/writer.rb +0 -209
  161. data/lib/textus/envelope.rb +0 -79
  162. data/lib/textus/etag.rb +0 -36
  163. data/lib/textus/jobs/base.rb +0 -23
  164. data/lib/textus/jobs/materialize.rb +0 -20
  165. data/lib/textus/jobs/plan.rb +0 -9
  166. data/lib/textus/jobs/planner.rb +0 -101
  167. data/lib/textus/jobs/retention.rb +0 -48
  168. data/lib/textus/jobs/sweep.rb +0 -27
  169. data/lib/textus/jobs/worker.rb +0 -67
  170. data/lib/textus/layout.rb +0 -91
  171. data/lib/textus/ports/job_store/job.rb +0 -65
  172. data/lib/textus/ports/job_store.rb +0 -123
  173. data/lib/textus/ports/raw_index.rb +0 -61
  174. data/lib/textus/role.rb +0 -36
  175. data/lib/textus/session.rb +0 -35
  176. 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 Get < Runner::Base
@@ -8,11 +8,11 @@ module Textus
8
8
 
9
9
  def invoke(store)
10
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)
11
+ spec = Textus::Action::Get.contract
12
+ result = store.gate.dispatch(spec: spec, inputs: { key: key }, role: resolved_role(store), surface: :cli)
13
13
  raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
14
14
 
15
- emit(result.to_h_for_wire)
15
+ emit(result)
16
16
  end
17
17
  end
18
18
  end
@@ -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 Init < 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
  # Launches the MCP stdio server in the current process. Blocks on stdin;
@@ -13,8 +13,8 @@ module Textus
13
13
  option :as_flag, "--as=ROLE"
14
14
 
15
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
16
+ role = resolved_role(store, default: Textus::Value::Role::AGENT)
17
+ Textus::Surface::MCP::Server.new(store: store, stdin: @stdin, stdout: @stdout, role: role).run
18
18
  0
19
19
  end
20
20
  end
@@ -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 Put < Runner::Base
@@ -12,16 +12,11 @@ module Textus
12
12
  raise UsageError.new("put requires --stdin in v1") unless use_stdin
13
13
 
14
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)
15
+ spec = Textus::Action::Put.contract
16
+ inputs = { key: key, meta: payload["_meta"] || {}, body: payload["body"] || "",
17
+ content: nil, if_etag: payload["if_etag"] }
18
+ result = store.gate.dispatch(spec: spec, inputs: inputs, role: resolved_role(store), surface: :cli)
19
+ emit(result)
25
20
  end
26
21
  end
27
22
  end
@@ -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 SchemaDiff < 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 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
@@ -1,11 +1,13 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  module MCP
4
4
  # Derives the entire MCP tool surface from the per-verb contracts (ADR 0039).
5
5
  # `build_tools` builds MCP::Tool instances for the SDK; `call` is the generic
6
6
  # dispatch: map JSON args -> (positional, keyword) per the contract, invoke
7
7
  # the verb through the role scope, then shape the return value. No per-tool code.
8
8
  module Catalog
9
+ PROJECTOR = Projector.new(view_key: :default, binder_method: :inputs_from_wire).freeze
10
+
9
11
  module_function
10
12
 
11
13
  WRITE_VERBS = %i[
@@ -41,9 +43,9 @@ module Textus
41
43
  end
42
44
 
43
45
  def names
44
- Textus::Action::VERBS
45
- .select { |_, klass| mcp_surfaced?(klass) }
46
- .keys.map(&:to_s)
46
+ PROJECTOR.names(
47
+ Textus::Action::VERBS.select { |_, klass| mcp_surfaced?(klass) },
48
+ )
47
49
  end
48
50
 
49
51
  # MCP-surfaced read verbs, by Dispatcher class namespace — the agent's
@@ -73,32 +75,13 @@ module Textus
73
75
  klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
74
76
  end
75
77
 
76
- def call(name, session:, store:, args:) # rubocop:disable Metrics/AbcSize
78
+ def call(name, session:, store:, args:)
77
79
  klass = Textus::Action::VERBS[name.to_sym]
78
80
  raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
79
81
 
82
+ PROJECTOR.dispatch(name, inputs: args, store:, role: session.role, session:)
83
+ rescue Textus::Gate::MissingArgs => e
80
84
  spec = klass.contract
81
- inputs = Textus::Contract::Binder.inputs_from_wire(spec, args)
82
-
83
- invoke = lambda do |effective_inputs|
84
- pos, kwargs = Textus::Contract::Binder.bind(spec, effective_inputs, session: session)
85
- spec.args.select(&:positional).zip(pos).each { |a, v| kwargs[a.name] = v unless kwargs.key?(a.name) }
86
- cmd_class = Textus::Gate::VERB_COMMAND.fetch(spec.verb) do
87
- raise Textus::MCP::ToolError.new("unknown verb: #{spec.verb}")
88
- end
89
- merged = kwargs.merge(role: session.role)
90
- filled = cmd_class.members.to_h { |m| [m, merged.key?(m) ? merged[m] : nil] }
91
- cmd = cmd_class.new(**filled)
92
- store.gate.dispatch(cmd)
93
- end
94
-
95
- result = if spec.around
96
- Textus::Contract::Around.with(spec.around, scope: store.as(session.role), inputs: inputs, session: session, &invoke)
97
- else
98
- invoke.call(inputs)
99
- end
100
- Textus::Contract::View.render(spec, :default, result, inputs)
101
- rescue Textus::Contract::MissingArgs => e
102
85
  raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
103
86
  rescue Textus::ContractDrift, CursorExpired
104
87
  raise
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  module MCP
4
4
  # Audit cursor fell off the keep window. Client should re-boot and
5
5
  # resume from the new latest_seq.
@@ -3,21 +3,21 @@
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)
13
+ def initialize(store:, role: Textus::Value::Role::DEFAULT, stdin: $stdin, stdout: $stdout)
14
14
  @store = store
15
15
  @role = role
16
16
  @stdin = stdin
17
17
  @stdout = stdout
18
18
  # Session built eagerly so the contract_etag is captured at server start.
19
19
  # Changes to manifest/hooks/schemas after this point are detected as drift.
20
- @session = Textus::Session.new(
20
+ @session = Textus::Store::Session.new(
21
21
  role: @role,
22
22
  cursor: @store.audit_log.latest_seq,
23
23
  propose_lane: @store.manifest.policy.propose_lane_for(@role),
@@ -61,7 +61,7 @@ module Textus
61
61
  raise_handler_error(e.message, Textus::ContractDrift::JSONRPC_CODE)
62
62
  rescue CursorExpired => e
63
63
  raise_handler_error(e.message, CursorExpired::JSONRPC_CODE)
64
- rescue Textus::Surfaces::MCP::ToolError => e
64
+ rescue Textus::Surface::MCP::ToolError => e
65
65
  raise_handler_error(e.message, ToolError::JSONRPC_CODE)
66
66
  rescue StandardError => e
67
67
  raise_handler_error("internal: #{e.class}: #{e.message}", -32_603)
@@ -93,7 +93,7 @@ module Textus
93
93
  raise_handler_error("resource read failed: #{e.message}", -32_603)
94
94
  end
95
95
 
96
- def contract_etag_now = Textus::Etag.for_contract(@store.root)
96
+ def contract_etag_now = Textus::Value::Etag.for_contract(@store.root)
97
97
 
98
98
  # The SDK parses JSON with symbolize_names:true, making all nested hash keys symbols.
99
99
  # Recursively stringify so Catalog.call receives string-keyed hashes throughout.
@@ -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
@@ -0,0 +1,27 @@
1
+ module Textus
2
+ module Surface
3
+ class Projector
4
+ def initialize(view_key: :default, binder_method: :inputs_from_wire)
5
+ @view_key = view_key
6
+ @binder_method = binder_method
7
+ end
8
+
9
+ def verbs(action_verbs = Textus::Action::VERBS)
10
+ action_verbs.select do |_verb, klass|
11
+ klass.respond_to?(:contract?) && klass.contract?
12
+ end
13
+ end
14
+
15
+ def names(action_verbs = Textus::Action::VERBS)
16
+ verbs(action_verbs).keys.map(&:to_s)
17
+ end
18
+
19
+ def dispatch(verb_name, inputs:, store:, role:, session: nil)
20
+ klass = Textus::Action::VERBS.fetch(verb_name.to_sym)
21
+ spec = klass.contract
22
+ bound = Textus::Gate::Binder.public_send(@binder_method, spec, inputs)
23
+ store.gate.dispatch(spec:, inputs: bound, role:, session:, surface: @view_key)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Surfaces
4
+ module Surface
5
5
  # Role-scoped identity carrier. Holds the acting identity (role,
6
6
  # correlation_id, dry_run) bound to a container. All verb methods
7
7
  # (put, get, accept, ...) are injected by textus.rb's define_method
@@ -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
@@ -0,0 +1,89 @@
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::String).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
+ # rubocop:disable Metrics/ParameterLists
24
+ def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
25
+ # rubocop:enable Metrics/ParameterLists
26
+ new(
27
+ protocol: Textus::PROTOCOL,
28
+ key: key,
29
+ lane: mentry.lane,
30
+ owner: mentry.owner,
31
+ path: path,
32
+ format: mentry.format,
33
+ uid: extract_uid(meta),
34
+ sources: extract_sources(meta),
35
+ etag: etag,
36
+ schema_ref: mentry.schema,
37
+ meta: meta,
38
+ body: body,
39
+ content: content,
40
+ freshness: freshness,
41
+ )
42
+ end
43
+
44
+ def self.extract_uid(meta)
45
+ v = meta.is_a?(Hash) ? meta["uid"] : nil
46
+ v.is_a?(String) ? v : nil
47
+ end
48
+
49
+ def self.extract_sources(meta)
50
+ v = meta.is_a?(Hash) ? meta["sources"] : nil
51
+ v.is_a?(Array) && !v.empty? ? v : nil
52
+ end
53
+
54
+ def with(**attrs) = self.class.new(to_h.merge(attrs))
55
+
56
+ def to_h_for_wire
57
+ h = {
58
+ "protocol" => protocol,
59
+ "key" => key,
60
+ "lane" => lane,
61
+ "owner" => owner,
62
+ "path" => path,
63
+ "format" => format,
64
+ "_meta" => meta,
65
+ "body" => body,
66
+ "etag" => etag,
67
+ "schema_ref" => schema_ref,
68
+ "uid" => uid,
69
+ }
70
+ h["sources"] = sources if sources
71
+ h["content"] = content unless content.nil?
72
+ freshness&.to_h_for_wire&.each { |k, v| h[k] = v }
73
+ h
74
+ end
75
+
76
+ def stale?
77
+ return false if freshness.nil?
78
+
79
+ freshness.stale == true
80
+ end
81
+
82
+ def fetching?
83
+ return false if freshness.nil?
84
+
85
+ freshness.fetching == true
86
+ end
87
+ end
88
+ end
89
+ 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::Geometry.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,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Value
5
+ # Unwraps Dry::Monads results at the Gate seam.
6
+ # Every action returns Success(value) or Failure(code:, message:, details:).
7
+ # This module converts Failure into an ActionError for surfaces (CLI, MCP)
8
+ # that expect exceptions.
9
+ module Result
10
+ def self.unwrap(result)
11
+ case result
12
+ when Dry::Monads::Result::Success then result.value!
13
+ when Dry::Monads::Result::Failure
14
+ failure = result.failure
15
+ raise ActionError.new(
16
+ failure[:code] || :internal,
17
+ failure[:message] || "action failed",
18
+ details: failure[:details] || {},
19
+ )
20
+ else
21
+ result
22
+ end
23
+ end
24
+ end
25
+ end
26
+ 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
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.54.2"
2
+ VERSION = "0.55.1"
3
3
  PROTOCOL = "textus/4"
4
4
  end
@@ -2,13 +2,13 @@ module Textus
2
2
  module Workflow
3
3
  class Loader
4
4
  def self.load_all(root)
5
- registry = Registry.new
6
- workflows_dir = File.join(root, "workflows")
7
- return registry unless File.directory?(workflows_dir)
5
+ geometry = Textus::Store::Geometry.new(root)
6
+ registry = Registry.new
7
+ return registry unless File.directory?(geometry.workflow_dir)
8
8
 
9
9
  collector = Collector.new(registry)
10
10
  Collector.with(collector) do
11
- Dir.glob(File.join(workflows_dir, "**", "*.rb")).each { |path| load path }
11
+ Dir.glob(File.join(geometry.workflow_dir, "**", "*.rb")).each { |path| load path }
12
12
  end
13
13
  registry
14
14
  end