textus 0.43.1 → 0.45.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +3 -3
  4. data/SPEC.md +15 -13
  5. data/docs/architecture/README.md +28 -9
  6. data/docs/reference/conventions.md +8 -9
  7. data/lib/textus/boot.rb +10 -7
  8. data/lib/textus/cli/group/fetch.rb +2 -2
  9. data/lib/textus/cli/group.rb +1 -0
  10. data/lib/textus/cli/runner.rb +187 -0
  11. data/lib/textus/cli/verb/build.rb +4 -4
  12. data/lib/textus/cli/verb/{fetch_stale.rb → fetch_all.rb} +2 -2
  13. data/lib/textus/cli/verb/get.rb +6 -5
  14. data/lib/textus/cli/verb/put.rb +3 -3
  15. data/lib/textus/cli/verb.rb +3 -0
  16. data/lib/textus/cli.rb +8 -2
  17. data/lib/textus/contract/around.rb +29 -0
  18. data/lib/textus/contract/binder.rb +88 -0
  19. data/lib/textus/contract/resources/cursor.rb +26 -0
  20. data/lib/textus/contract/sources.rb +39 -0
  21. data/lib/textus/contract/view.rb +15 -0
  22. data/lib/textus/contract.rb +78 -10
  23. data/lib/textus/dispatcher.rb +5 -6
  24. data/lib/textus/hooks/context.rb +24 -2
  25. data/lib/textus/maintenance/key_delete_prefix.rb +6 -4
  26. data/lib/textus/maintenance/key_mv_prefix.rb +7 -5
  27. data/lib/textus/maintenance/migrate.rb +12 -8
  28. data/lib/textus/maintenance/rule_lint.rb +4 -2
  29. data/lib/textus/maintenance/zone_mv.rb +7 -5
  30. data/lib/textus/manifest/entry/base.rb +1 -1
  31. data/lib/textus/mcp/catalog.rb +17 -33
  32. data/lib/textus/projection.rb +2 -2
  33. data/lib/textus/read/audit.rb +19 -0
  34. data/lib/textus/read/blame.rb +11 -1
  35. data/lib/textus/read/deps.rb +15 -1
  36. data/lib/textus/read/doctor.rb +8 -0
  37. data/lib/textus/read/freshness.rb +10 -0
  38. data/lib/textus/read/get.rb +88 -22
  39. data/lib/textus/read/list.rb +3 -2
  40. data/lib/textus/read/published.rb +7 -0
  41. data/lib/textus/read/pulse.rb +1 -0
  42. data/lib/textus/read/rdeps.rb +14 -0
  43. data/lib/textus/read/rule_explain.rb +84 -0
  44. data/lib/textus/read/rule_list.rb +39 -0
  45. data/lib/textus/read/schema_envelope.rb +5 -3
  46. data/lib/textus/read/uid.rb +9 -0
  47. data/lib/textus/read/where.rb +8 -0
  48. data/lib/textus/role_scope.rb +34 -6
  49. data/lib/textus/schema/tools.rb +12 -3
  50. data/lib/textus/version.rb +1 -1
  51. data/lib/textus/write/accept.rb +8 -0
  52. data/lib/textus/write/{publish.rb → build.rb} +16 -7
  53. data/lib/textus/write/delete.rb +13 -0
  54. data/lib/textus/write/fetch_all.rb +3 -2
  55. data/lib/textus/write/fetch_orchestrator.rb +1 -1
  56. data/lib/textus/write/fetch_worker.rb +3 -2
  57. data/lib/textus/write/mv.rb +16 -0
  58. data/lib/textus/write/propose.rb +12 -4
  59. data/lib/textus/write/put.rb +11 -6
  60. data/lib/textus/write/reject.rb +8 -0
  61. data/lib/textus/write/retention_sweep.rb +9 -0
  62. metadata +11 -29
  63. data/lib/textus/cli/verb/accept.rb +0 -16
  64. data/lib/textus/cli/verb/audit.rb +0 -34
  65. data/lib/textus/cli/verb/blame.rb +0 -17
  66. data/lib/textus/cli/verb/delete.rb +0 -17
  67. data/lib/textus/cli/verb/deps.rb +0 -14
  68. data/lib/textus/cli/verb/freshness.rb +0 -17
  69. data/lib/textus/cli/verb/key_delete.rb +0 -24
  70. data/lib/textus/cli/verb/list.rb +0 -16
  71. data/lib/textus/cli/verb/migrate.rb +0 -18
  72. data/lib/textus/cli/verb/mv.rb +0 -27
  73. data/lib/textus/cli/verb/propose.rb +0 -28
  74. data/lib/textus/cli/verb/published.rb +0 -13
  75. data/lib/textus/cli/verb/pulse.rb +0 -26
  76. data/lib/textus/cli/verb/rdeps.rb +0 -14
  77. data/lib/textus/cli/verb/reject.rb +0 -16
  78. data/lib/textus/cli/verb/retain.rb +0 -19
  79. data/lib/textus/cli/verb/rule_explain.rb +0 -16
  80. data/lib/textus/cli/verb/rule_lint.rb +0 -18
  81. data/lib/textus/cli/verb/rule_list.rb +0 -29
  82. data/lib/textus/cli/verb/schema.rb +0 -15
  83. data/lib/textus/cli/verb/uid.rb +0 -15
  84. data/lib/textus/cli/verb/where.rb +0 -14
  85. data/lib/textus/cli/verb/zone_mv.rb +0 -19
  86. data/lib/textus/read/get_or_fetch.rb +0 -69
  87. data/lib/textus/read/policy_explain.rb +0 -46
  88. data/lib/textus/read/rules.rb +0 -24
@@ -1,14 +1,15 @@
1
1
  module Textus
2
2
  class CLI
3
3
  class Verb
4
- class Get < Verb
5
- command_name "get"
6
-
4
+ class Get < Runner::Base
5
+ self.spec = Textus::Read::Get.contract
7
6
  option :as_flag, "--as=ROLE"
7
+ option :no_fetch, "--no-fetch"
8
8
 
9
- def call(store)
9
+ def invoke(store)
10
10
  key = positional.shift or raise UsageError.new("get requires a key")
11
- result = session_for(store).get_or_fetch(key)
11
+ kw = no_fetch.nil? ? {} : { fetch: false }
12
+ result = session_for(store).get(key, **kw)
12
13
  raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
13
14
 
14
15
  emit(result.to_h_for_wire)
@@ -1,14 +1,14 @@
1
1
  module Textus
2
2
  class CLI
3
3
  class Verb
4
- class Put < Verb
5
- command_name "put"
4
+ class Put < Runner::Base
5
+ self.spec = Textus::Write::Put.contract
6
6
 
7
7
  option :as_flag, "--as=ROLE"
8
8
  option :use_stdin, "--stdin"
9
9
  option :fetch_name, "--fetch=NAME"
10
10
 
11
- def call(store)
11
+ def invoke(store)
12
12
  key = positional.shift or raise UsageError.new("put requires a key")
13
13
  raise UsageError.new("put requires --stdin in v1") unless use_stdin
14
14
 
@@ -108,6 +108,9 @@ module Textus
108
108
  def session_for(store)
109
109
  store.as(resolved_role(store))
110
110
  end
111
+
112
+ # The input stream — the source for a `cli_stdin` envelope (ADR 0068).
113
+ attr_reader :stdin
111
114
  end
112
115
  end
113
116
  end
data/lib/textus/cli.rb CHANGED
@@ -7,9 +7,15 @@ module Textus
7
7
  # declares `command_name "X"` and has no `parent_group` is a top-level
8
8
  # verb. Sorted alphabetically for stable help output. Adding a new
9
9
  # verb requires only a new file declaring its `command_name`.
10
+ #
11
+ # `k.name` gates out anonymous (Class.new) subclasses: real verbs are always
12
+ # named constants (generated Gen* or hand-authored classes), so this is a
13
+ # no-op in production but keeps throwaway test fixtures from leaking into the
14
+ # registry (and tripping the reconciliation guards order-dependently).
10
15
  def self.verbs
16
+ Runner.install!
11
17
  Verb.descendants
12
- .select { |k| k.command_name && k.parent_group.nil? }
18
+ .select { |k| k.name && k.command_name && k.parent_group.nil? }
13
19
  .sort_by(&:command_name)
14
20
  .to_h { |k| [k.command_name, k] }
15
21
  end
@@ -91,7 +97,7 @@ module Textus
91
97
  textus put KEY --stdin [--fetch=NAME] --as=ROLE
92
98
  textus freshness [--prefix=KEY] [--zone=Z]
93
99
  textus fetch KEY
94
- textus fetch stale [--prefix=KEY] [--zone=Z]
100
+ textus fetch all [--prefix=KEY] [--zone=Z]
95
101
  textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
96
102
  textus blame KEY [--limit=N]
97
103
  textus doctor
@@ -0,0 +1,29 @@
1
+ module Textus
2
+ module Contract
3
+ # Registry of named, stateful wrappers a verb may declare via `around :name`.
4
+ # A resource implements
5
+ # `wrap(scope:, inputs:, session:) { |effective_inputs| ... }`:
6
+ # it may adjust the inputs before the call and post-process the result after
7
+ # — exactly what build's lock and pulse's cursor need, without a hand-authored
8
+ # CLI class (ADR 0068). `session:` is the dispatching session (nil for the
9
+ # sessionless CLI/Ruby surfaces, present for MCP), so a session-aware resource
10
+ # like the cursor can defer to the session's own state instead of its file.
11
+ module Around
12
+ @registry = {}
13
+
14
+ module_function
15
+
16
+ def register(name, resource)
17
+ @registry[name] = resource
18
+ end
19
+
20
+ def fetch(name)
21
+ @registry.fetch(name) { raise "no around resource registered: #{name.inspect}" }
22
+ end
23
+
24
+ def with(name, scope:, inputs:, session: nil, &call)
25
+ fetch(name).wrap(scope: scope, inputs: inputs, session: session, &call)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,88 @@
1
+ module Textus
2
+ module Contract
3
+ # Raised when a required arg is absent from the bound input. Surface
4
+ # adapters translate this to their native error (MCP ToolError, CLI
5
+ # UsageError); a direct Ruby call lets it surface as-is.
6
+ class MissingArgs < Textus::Error
7
+ attr_reader :spec, :missing
8
+
9
+ def initialize(spec, missing)
10
+ @spec = spec
11
+ @missing = missing
12
+ super("missing_args", "#{spec.verb}: missing #{missing.map(&:wire).join(", ")}")
13
+ end
14
+ end
15
+
16
+ # The single argument binder for every surface (spike: collapses the three
17
+ # historical implementations — MCP::Catalog.map_args, CLI::Runner.call_args,
18
+ # and RoleScope's default-injection loop — into one algorithm).
19
+ #
20
+ # Input is a uniform `inputs` hash keyed by arg NAME (the use-case kwarg
21
+ # name, never the wire name): each surface normalizes its own raw transport
22
+ # shape (MCP JSON keyed by wire-name, CLI argv+flags, Ruby args+kwargs) into
23
+ # this hash. Binder owns the shared algorithm and nothing transport-specific:
24
+ #
25
+ # 1. validate every required arg is present in `inputs`;
26
+ # 2. for absentees, fall back to session_default (when a session is given)
27
+ # then to the literal default; otherwise omit the arg entirely;
28
+ # 3. split into the (positional, keyword) pair to splat into the use-case,
29
+ # routing by `arg.positional`.
30
+ #
31
+ # Returns `[positional_array, keyword_hash]` — exactly what
32
+ # `RoleScope#<verb>(*pos, **kw)` expects.
33
+ module Binder
34
+ module_function
35
+
36
+ # Validation is unconditional: a `required:` arg absent from `inputs` is a
37
+ # contract violation on every surface (ADR 0069). `required:` is now an
38
+ # honest contract invariant, not a surface policy — args the use-case
39
+ # treats as optional (e.g. `meta`, whose real requiredness lives in schema
40
+ # validation downstream) are declared `required: false`, so this check
41
+ # never fires spuriously and never needs an opt-out.
42
+ def bind(spec, inputs, session: nil)
43
+ missing = spec.required_args.reject { |a| inputs.key?(a.name) }
44
+ raise MissingArgs.new(spec, missing) unless missing.empty?
45
+
46
+ pos = []
47
+ kw = {}
48
+ spec.args.each do |a|
49
+ if inputs.key?(a.name)
50
+ value = inputs[a.name]
51
+ elsif a.session_default && session
52
+ value = session.public_send(a.session_default)
53
+ elsif !a.default.nil?
54
+ value = a.default
55
+ else
56
+ next
57
+ end
58
+
59
+ if a.positional
60
+ pos << value
61
+ else
62
+ kw[a.name] = value
63
+ end
64
+ end
65
+ [pos, kw]
66
+ end
67
+
68
+ # Normalize an ordered positional list + a by-name keyword hash (the shape
69
+ # CLI argv+flags and Ruby args+kwargs both arrive in) into the uniform
70
+ # by-name `inputs` hash bind expects. Positionals beyond what was supplied
71
+ # are dropped so bind's required-check sees them as absent.
72
+ def inputs_from_ordered(spec, ordered_positionals, by_name_keywords)
73
+ names = spec.args.select(&:positional).map(&:name)
74
+ names.zip(ordered_positionals).to_h.compact.merge(by_name_keywords)
75
+ end
76
+
77
+ # Normalize a raw transport hash keyed by WIRE name (the shape MCP JSON
78
+ # arrives in) into the uniform by-name `inputs` hash bind expects. Keys
79
+ # not declared on the contract are ignored.
80
+ def inputs_from_wire(spec, raw)
81
+ raw ||= {}
82
+ spec.args.each_with_object({}) do |a, h|
83
+ h[a.name] = raw[a.wire.to_s] if raw.key?(a.wire.to_s)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Contract
3
+ module Resources
4
+ # Reads the persisted file cursor as the `since` default when the caller
5
+ # did not supply one, runs pulse, then persists the returned cursor.
6
+ # Replaces CLI::Verb::Pulse's hand-coded CursorStore read/write (ADR 0068).
7
+ #
8
+ # A session-bearing surface (MCP) carries its own cursor via the contract's
9
+ # `session_default: :cursor`, so this defers entirely when a session is
10
+ # present — it is the sessionless CLI/Ruby surfaces that need the file.
11
+ class Cursor
12
+ def wrap(scope:, inputs:, session:)
13
+ return yield(inputs) if session
14
+
15
+ store = Textus::CursorStore.new(root: scope.container.root, role: scope.role)
16
+ effective = inputs.key?(:since) ? inputs : inputs.merge(since: store.read)
17
+ result = yield(effective)
18
+ store.write(result["cursor"]) if result.is_a?(Hash) && result["cursor"]
19
+ result
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ Textus::Contract::Around.register(:cursor, Textus::Contract::Resources::Cursor.new)
@@ -0,0 +1,39 @@
1
+ require "json"
2
+
3
+ module Textus
4
+ module Contract
5
+ # CLI-only input acquisition. Transforms entries of the uniform `inputs`
6
+ # hash that declare a `source:`/`coerce:`, and builds `inputs` from a
7
+ # `cli_stdin` envelope — so put/propose/migrate/rule_lint/audit need no
8
+ # hand-authored CLI class (ADR 0068). MCP receives typed JSON, so these
9
+ # never run there.
10
+ module Sources
11
+ module_function
12
+
13
+ # Apply per-arg :file sources (value is a path -> file contents) and
14
+ # :coerce callables to a by-name inputs hash. Returns a new hash.
15
+ def acquire(spec, inputs)
16
+ spec.args.each_with_object(inputs.dup) do |a, h|
17
+ next unless h.key?(a.name)
18
+
19
+ h[a.name] = File.read(h[a.name]) if a.source == :file
20
+ h[a.name] = a.coerce.call(h[a.name]) if a.coerce
21
+ end
22
+ end
23
+
24
+ # Parse a cli_stdin :json envelope into a by-name inputs hash, mapping
25
+ # envelope keys (wire-names) to arg names.
26
+ def from_stdin(spec, stream)
27
+ return {} unless spec.cli_stdin == :json
28
+
29
+ raw = stream.read.to_s
30
+ return {} if raw.strip.empty? # no envelope piped -> required args surface as missing
31
+
32
+ envelope = JSON.parse(raw)
33
+ spec.args.each_with_object({}) do |a, h|
34
+ h[a.name] = envelope[a.wire.to_s] if envelope.key?(a.wire.to_s)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Contract
3
+ # Renders a use-case result for a surface, using the verb's declared view
4
+ # (falling back to the default). The single replacement for the old
5
+ # response/cli_response split and the Proc#arity sniff: views are always
6
+ # called as (result, inputs); a one-parameter view ignores inputs.
7
+ module View
8
+ module_function
9
+
10
+ def render(spec, surface, result, inputs)
11
+ spec.view(surface).call(result, inputs)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -8,7 +8,21 @@ module Textus
8
8
  # use-case as a positional (e.g. `get(key)`); otherwise as a keyword.
9
9
  # `session_default` names a zero-arg method on `Textus::Session` (Symbol)
10
10
  # that supplies the value when the wire arg is absent; `nil` means no default.
11
- Arg = Data.define(:name, :type, :required, :positional, :session_default, :description)
11
+ # `wire_name` is the name the arg carries on the wire (MCP JSON property / CLI
12
+ # envelope key) when it must differ from the use-case kwarg `name` — e.g. `put`
13
+ # takes the `meta:` kwarg but exposes `_meta` on the wire to match what `get`
14
+ # returns and what the CLI `--stdin` envelope already speaks (ADR 0057).
15
+ # `source: :file` (CLI only) reads the arg's value as a path -> file
16
+ # contents; `coerce:` is a callable applied to the raw value (CLI only);
17
+ # `cli_default:` supplies a CLI-specific default that diverges from the
18
+ # contract `default` the agent surfaces use (`:__unset` sentinel = none).
19
+ Arg = Data.define(
20
+ :name, :type, :required, :positional, :session_default,
21
+ :description, :wire_name, :default, :source, :coerce, :cli_default
22
+ ) do
23
+ # The name used on the wire (defaults to the kwarg name).
24
+ def wire = wire_name || name
25
+ end
12
26
 
13
27
  JSON_TYPES = {
14
28
  String => "string", Integer => "integer", Hash => "object",
@@ -19,8 +33,21 @@ module Textus
19
33
  JSON_TYPES.fetch(type) { raise ArgumentError.new("no JSON type mapping for #{type.inspect}") }
20
34
  end
21
35
 
22
- Spec = Data.define(:verb, :summary, :args, :surfaces, :response) do
36
+ Spec = Data.define(:verb, :summary, :args, :surfaces, :views, :cli, :around, :cli_stdin) do
23
37
  def mcp? = surfaces.include?(:mcp)
38
+ def cli? = surfaces.include?(:cli)
39
+
40
+ # The output shaper for a surface; falls back to the default view. Every
41
+ # view is invoked uniformly as `view.call(result, inputs)` — a view that
42
+ # declares one parameter ignores `inputs` (procs tolerate extra args).
43
+ def view(surface = :default) = views[surface] || views.fetch(:default)
44
+
45
+ # Operator-facing command path. Defaults to the verb token; grouped verbs
46
+ # declare e.g. `cli "schema show"`.
47
+ def cli_path = cli || verb.to_s
48
+ def cli_words = cli_path.split
49
+ def cli_group = cli_words.size > 1 ? cli_words.first : nil
50
+ def cli_leaf = cli_words.last
24
51
 
25
52
  def required_args = args.select(&:required)
26
53
 
@@ -31,9 +58,9 @@ module Textus
31
58
  props = args.to_h do |a|
32
59
  h = { "type" => Contract.json_type(a.type) }
33
60
  h["description"] = a.description if a.description
34
- [a.name.to_s, h]
61
+ [a.wire.to_s, h]
35
62
  end
36
- { type: "object", properties: props, required: required_args.map { |a| a.name.to_s } }
63
+ { type: "object", properties: props, required: required_args.map { |a| a.wire.to_s } }
37
64
  end
38
65
  end
39
66
 
@@ -70,18 +97,56 @@ module Textus
70
97
  end
71
98
  end
72
99
 
73
- def arg(name, type, required: false, positional: false, session_default: nil, description: nil)
100
+ def cli(path = nil)
101
+ if path
102
+ raise "contract already built; declare cli before reading .contract" if defined?(@__contract) && @__contract
103
+
104
+ @__cli = path.to_s
105
+ else
106
+ @__cli
107
+ end
108
+ end
109
+
110
+ # Declare a stateful wrapper resource (Contract::Around) to run around
111
+ # dispatch — e.g. `around :cursor` (pulse) or `around :build_lock` (build).
112
+ def around(name = nil)
113
+ return @__around unless name
114
+
115
+ raise "contract already built; declare around before reading .contract" if defined?(@__contract) && @__contract
116
+
117
+ @__around = name
118
+ end
119
+
120
+ def arg(name, type, required: false, positional: false, session_default: nil, description: nil, wire_name: nil, default: nil, source: nil, coerce: nil, cli_default: :__unset) # rubocop:disable Metrics/ParameterLists,Layout/LineLength
74
121
  raise "contract already built; declare args before reading .contract" if defined?(@__contract) && @__contract
75
122
 
76
123
  (@__args ||= []) << Arg.new(
77
124
  name: name, type: type, required: required,
78
- positional: positional, session_default: session_default, description: description
125
+ positional: positional, session_default: session_default,
126
+ description: description, wire_name: wire_name, default: default,
127
+ source: source, coerce: coerce, cli_default: cli_default
79
128
  )
80
129
  end
81
130
 
82
- def response(&blk)
83
- @__response = blk if blk
84
- @__response || ->(v) { v }
131
+ # Verb-level: the CLI reads its inputs from a stdin envelope of this mode.
132
+ # `:json` parses stdin as a JSON object and distributes its keys to args
133
+ # by wire-name. nil means no stdin acquisition.
134
+ def cli_stdin(mode = :__read)
135
+ return @__cli_stdin if mode == :__read
136
+
137
+ raise "contract already built; declare cli_stdin before reading .contract" if defined?(@__contract) && @__contract
138
+
139
+ @__cli_stdin = mode
140
+ end
141
+
142
+ # Declare an output shaper. `view { ... }` is the default (MCP + Ruby);
143
+ # `view(:cli) { ... }` overrides for the CLI. Both receive (result, inputs).
144
+ def view(surface = :default, &blk)
145
+ return (@__views ||= {})[surface] unless blk
146
+
147
+ raise "contract already built; declare view before reading .contract" if defined?(@__contract) && @__contract
148
+
149
+ (@__views ||= {})[surface] = blk
85
150
  end
86
151
 
87
152
  def contract?
@@ -97,7 +162,10 @@ module Textus
97
162
  summary: @__summary,
98
163
  args: (@__args || []).freeze,
99
164
  surfaces: (@__surfaces || []).freeze,
100
- response: response,
165
+ views: ((@__views ||= {})[:default] ||= ->(v, _i) { v }) && @__views,
166
+ cli: @__cli,
167
+ around: @__around,
168
+ cli_stdin: @__cli_stdin,
101
169
  )
102
170
  end
103
171
  # rubocop:enable Naming/MemoizedInstanceVariableName
@@ -11,14 +11,13 @@ module Textus
11
11
  mv: Textus::Write::Mv,
12
12
  accept: Textus::Write::Accept,
13
13
  reject: Textus::Write::Reject,
14
- publish: Textus::Write::Publish,
14
+ build: Textus::Write::Build,
15
15
  fetch: Textus::Write::FetchWorker,
16
16
  fetch_all: Textus::Write::FetchAll,
17
- retention_sweep: Textus::Write::RetentionSweep,
17
+ retain: Textus::Write::RetentionSweep,
18
18
 
19
19
  # Read
20
20
  get: Textus::Read::Get,
21
- get_or_fetch: Textus::Read::GetOrFetch,
22
21
  list: Textus::Read::List,
23
22
  where: Textus::Read::Where,
24
23
  uid: Textus::Read::Uid,
@@ -29,14 +28,14 @@ module Textus
29
28
  deps: Textus::Read::Deps,
30
29
  rdeps: Textus::Read::Rdeps,
31
30
  pulse: Textus::Read::Pulse,
32
- policy_explain: Textus::Read::PolicyExplain,
31
+ rule_explain: Textus::Read::RuleExplain,
32
+ rule_list: Textus::Read::RuleList,
33
33
  published: Textus::Read::Published,
34
- schema: Textus::Read::SchemaEnvelope,
34
+ schema_show: Textus::Read::SchemaEnvelope,
35
35
  validate_all: Textus::Read::ValidateAll,
36
36
  doctor: Textus::Read::Doctor,
37
37
  boot: Textus::Read::Boot,
38
38
  retainable: Textus::Read::Retainable,
39
- rules: Textus::Read::Rules,
40
39
 
41
40
  # Maintenance
42
41
  migrate: Textus::Maintenance::Migrate,
@@ -28,8 +28,17 @@ module Textus
28
28
  @scope
29
29
  end
30
30
 
31
- # read
32
- def get(key) = @scope.get(key)
31
+ # read — a deliberately pure-observation surface: NOTHING here fetches
32
+ # (`list`/`deps`/`freshness` don't either). The invariant is that a hook
33
+ # observes current state and never triggers an I/O cascade. `get` bypasses
34
+ # the read-through behavior (ADR 0062) and reads with fetch:false directly,
35
+ # because read-through inside a hook would: (1) fire fetch events → hooks →
36
+ # unbounded reentrancy; (2) spawn the orchestrator's threads/fork from
37
+ # inside a hook callback; (3) probe the single-flight fetch lock its own
38
+ # enclosing fetch may hold (deadlock); (4) inject network latency into
39
+ # every hook read. With the merged Read::Get class, `fetch:false` (the
40
+ # method default) guarantees no orchestrator is built.
41
+ def get(key) = pure_reader.call(key)
33
42
  def list(**) = @scope.list(**)
34
43
  def deps(key) = @scope.deps(key)
35
44
  def freshness(key) = @scope.freshness(key)
@@ -50,6 +59,19 @@ module Textus
50
59
  def inspect
51
60
  "#<Textus::Hooks::Context role=#{@role} correlation_id=#{@correlation_id}>"
52
61
  end
62
+
63
+ private
64
+
65
+ def pure_reader
66
+ @pure_reader ||= Textus::Read::Get.new(
67
+ container: @scope.container,
68
+ call: Textus::Call.build(
69
+ role: @scope.role,
70
+ correlation_id: @scope.correlation_id,
71
+ dry_run: @scope.dry_run?,
72
+ ),
73
+ )
74
+ end
53
75
  end
54
76
  end
55
77
  end
@@ -7,16 +7,18 @@ module Textus
7
7
  verb :key_delete_prefix
8
8
  summary "Bulk-delete every leaf key under prefix."
9
9
  surfaces :cli, :ruby, :mcp
10
- arg :prefix, String, required: true
11
- arg :dry_run, :boolean
12
- response(&:to_h)
10
+ cli "key delete-prefix"
11
+ arg :prefix, String, required: true, positional: true, description: "every leaf key under this dotted prefix is deleted"
12
+ arg :dry_run, :boolean, default: true, cli_default: false,
13
+ description: "agents plan by default; the CLI applies by default (pass --dry-run to plan only)"
14
+ view { |v, _i| v.to_h }
13
15
 
14
16
  def initialize(container:, call:)
15
17
  @container = container
16
18
  @call = call
17
19
  end
18
20
 
19
- def call(prefix:, dry_run: false)
21
+ def call(prefix, dry_run: true)
20
22
  raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
21
23
 
22
24
  leaves = Read::List.new(container: @container)
@@ -8,17 +8,19 @@ module Textus
8
8
  verb :key_mv_prefix
9
9
  summary "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false."
10
10
  surfaces :cli, :ruby, :mcp
11
- arg :from_prefix, String, required: true
12
- arg :to_prefix, String, required: true
13
- arg :dry_run, :boolean
14
- response(&:to_h)
11
+ cli "key mv-prefix"
12
+ arg :from_prefix, String, required: true, positional: true, description: "dotted prefix whose leaf keys are renamed"
13
+ arg :to_prefix, String, required: true, positional: true, description: "dotted prefix the keys are renamed to"
14
+ arg :dry_run, :boolean, default: true, cli_default: false,
15
+ description: "agents plan by default; the CLI applies by default (pass --dry-run to plan only)"
16
+ view { |v, _i| v.to_h }
15
17
 
16
18
  def initialize(container:, call:)
17
19
  @container = container
18
20
  @call = call
19
21
  end
20
22
 
21
- def call(from_prefix:, to_prefix:, dry_run: false)
23
+ def call(from_prefix, to_prefix, dry_run: true)
22
24
  raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
23
25
 
24
26
  leaves = list_leaves_under(from_prefix)
@@ -10,16 +10,18 @@ module Textus
10
10
  verb :migrate
11
11
  summary "Run a YAML migration plan (multi-op)."
12
12
  surfaces :cli, :ruby, :mcp
13
- arg :plan_yaml, String, required: true
14
- arg :dry_run, :boolean
15
- response(&:to_h)
13
+ arg :plan_yaml, String, required: true, positional: true, source: :file,
14
+ description: "path to the YAML migration plan (zone_mv, key_mv_prefix, key_delete_prefix ops run in order)"
15
+ arg :dry_run, :boolean, default: true, cli_default: false,
16
+ description: "agents plan by default; the CLI applies by default (pass --dry-run to plan only)"
17
+ view { |v, _i| v.to_h }
16
18
 
17
19
  def initialize(container:, call:)
18
20
  @container = container
19
21
  @call = call
20
22
  end
21
23
 
22
- def call(plan_yaml:, dry_run: false)
24
+ def call(plan_yaml, dry_run: true)
23
25
  raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
24
26
  raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
25
27
 
@@ -40,11 +42,13 @@ module Textus
40
42
  private
41
43
 
42
44
  def invoke_op(op_name, op_hash, dry_run:)
43
- kwargs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
44
45
  klass = op_class(op_name)
45
- klass.new(
46
- container: @container, call: @call,
47
- ).call(**kwargs)
46
+ inputs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
47
+ # Each op now carries positional args (from/to, from_prefix/to_prefix,
48
+ # prefix); split the YAML fields into (positional, keyword) via the op's
49
+ # own contract so we call its #call signature correctly (ADR 0066/0068).
50
+ args, kwargs = Textus::Contract::Binder.bind(klass.contract, inputs)
51
+ klass.new(container: @container, call: @call).call(*args, **kwargs)
48
52
  end
49
53
 
50
54
  def op_class(op_name)
@@ -11,8 +11,10 @@ module Textus
11
11
  verb :rule_lint
12
12
  summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
13
13
  surfaces :cli, :ruby, :mcp
14
- arg :candidate_yaml, String, required: true
15
- response(&:to_h)
14
+ cli "rule lint"
15
+ arg :candidate_yaml, String, required: true, wire_name: :against, source: :file,
16
+ description: "path to candidate manifest YAML; its `rules:` block is diffed against the live manifest"
17
+ view { |v, _i| v.to_h }
16
18
 
17
19
  def initialize(container:, call:)
18
20
  @container = container
@@ -11,10 +11,12 @@ module Textus
11
11
  verb :zone_mv
12
12
  summary "Rename a zone — manifest + files. Refuses if destination exists."
13
13
  surfaces :cli, :ruby, :mcp
14
- arg :from, String, required: true
15
- arg :to, String, required: true
16
- arg :dry_run, :boolean
17
- response(&:to_h)
14
+ cli "zone mv"
15
+ arg :from, String, required: true, positional: true, description: "current zone name"
16
+ arg :to, String, required: true, positional: true, description: "new zone name; refused if a zone by this name already exists"
17
+ arg :dry_run, :boolean, default: true, cli_default: false,
18
+ description: "agents plan by default; the CLI applies by default (pass --dry-run to plan only)"
19
+ view { |v, _i| v.to_h }
18
20
 
19
21
  def initialize(container:, call:)
20
22
  @container = container
@@ -23,7 +25,7 @@ module Textus
23
25
  @root = container.root
24
26
  end
25
27
 
26
- def call(from:, to:, dry_run: false)
28
+ def call(from, to, dry_run: true)
27
29
  raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
28
30
  raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
29
31
 
@@ -86,7 +86,7 @@ module Textus
86
86
  end
87
87
 
88
88
  # Returns: { kind: :built|:leaves, value: ... } to be accumulated by
89
- # Write::Publish, or nil to skip.
89
+ # Write::Build, or nil to skip.
90
90
  def publish_via(pctx, prefix: nil)
91
91
  publish_mode.publish(pctx, prefix: prefix)
92
92
  end