textus 0.43.2 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +56 -29
  4. data/SPEC.md +24 -22
  5. data/docs/architecture/README.md +32 -32
  6. data/docs/reference/conventions.md +8 -9
  7. data/lib/textus/boot.rb +4 -4
  8. data/lib/textus/builder/pipeline.rb +11 -42
  9. data/lib/textus/builder/renderer/markdown.rb +4 -8
  10. data/lib/textus/cli/group/fetch.rb +2 -2
  11. data/lib/textus/cli/group.rb +1 -0
  12. data/lib/textus/cli/runner.rb +187 -0
  13. data/lib/textus/cli/verb/build.rb +4 -4
  14. data/lib/textus/cli/verb/{fetch_stale.rb → fetch_all.rb} +2 -2
  15. data/lib/textus/cli/verb/get.rb +6 -5
  16. data/lib/textus/cli/verb/put.rb +3 -3
  17. data/lib/textus/cli/verb.rb +3 -0
  18. data/lib/textus/cli.rb +37 -3
  19. data/lib/textus/container.rb +3 -15
  20. data/lib/textus/contract/around.rb +29 -0
  21. data/lib/textus/contract/binder.rb +88 -0
  22. data/lib/textus/contract/resources/cursor.rb +26 -0
  23. data/lib/textus/contract/sources.rb +39 -0
  24. data/lib/textus/contract/view.rb +15 -0
  25. data/lib/textus/contract.rb +68 -8
  26. data/lib/textus/dispatcher.rb +6 -6
  27. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  28. data/lib/textus/doctor/check/sentinels.rb +1 -1
  29. data/lib/textus/domain/policy/predicates/fresh_within.rb +6 -5
  30. data/lib/textus/envelope/io/writer.rb +34 -0
  31. data/lib/textus/hooks/context.rb +24 -2
  32. data/lib/textus/layout.rb +8 -0
  33. data/lib/textus/maintenance/key_delete_prefix.rb +8 -5
  34. data/lib/textus/maintenance/key_mv_prefix.rb +18 -6
  35. data/lib/textus/maintenance/migrate.rb +14 -10
  36. data/lib/textus/maintenance/rule_lint.rb +5 -4
  37. data/lib/textus/maintenance/zone_mv.rb +9 -6
  38. data/lib/textus/manifest/entry/base.rb +1 -1
  39. data/lib/textus/mcp/catalog.rb +6 -33
  40. data/lib/textus/ports/publisher.rb +3 -2
  41. data/lib/textus/ports/sentinel_store.rb +8 -7
  42. data/lib/textus/projection.rb +6 -5
  43. data/lib/textus/read/audit.rb +19 -0
  44. data/lib/textus/read/blame.rb +11 -1
  45. data/lib/textus/read/boot.rb +1 -1
  46. data/lib/textus/read/capabilities.rb +70 -0
  47. data/lib/textus/read/deps.rb +15 -1
  48. data/lib/textus/read/doctor.rb +8 -0
  49. data/lib/textus/read/freshness.rb +10 -0
  50. data/lib/textus/read/get.rb +87 -22
  51. data/lib/textus/read/list.rb +2 -1
  52. data/lib/textus/read/published.rb +7 -0
  53. data/lib/textus/read/pulse.rb +2 -1
  54. data/lib/textus/read/rdeps.rb +14 -0
  55. data/lib/textus/read/rule_explain.rb +84 -0
  56. data/lib/textus/read/rule_list.rb +39 -0
  57. data/lib/textus/read/schema_envelope.rb +3 -2
  58. data/lib/textus/read/uid.rb +9 -0
  59. data/lib/textus/read/where.rb +8 -0
  60. data/lib/textus/role_scope.rb +34 -6
  61. data/lib/textus/schema/tools.rb +12 -3
  62. data/lib/textus/store.rb +47 -24
  63. data/lib/textus/version.rb +1 -1
  64. data/lib/textus/write/accept.rb +8 -0
  65. data/lib/textus/write/{publish.rb → build.rb} +16 -7
  66. data/lib/textus/write/delete.rb +13 -0
  67. data/lib/textus/write/fetch_all.rb +2 -1
  68. data/lib/textus/write/fetch_orchestrator.rb +1 -1
  69. data/lib/textus/write/fetch_worker.rb +2 -2
  70. data/lib/textus/write/mv.rb +16 -0
  71. data/lib/textus/write/propose.rb +8 -3
  72. data/lib/textus/write/put.rb +3 -3
  73. data/lib/textus/write/reject.rb +8 -0
  74. data/lib/textus/write/retention_sweep.rb +9 -0
  75. metadata +12 -29
  76. data/lib/textus/cli/verb/accept.rb +0 -16
  77. data/lib/textus/cli/verb/audit.rb +0 -34
  78. data/lib/textus/cli/verb/blame.rb +0 -17
  79. data/lib/textus/cli/verb/delete.rb +0 -17
  80. data/lib/textus/cli/verb/deps.rb +0 -14
  81. data/lib/textus/cli/verb/freshness.rb +0 -17
  82. data/lib/textus/cli/verb/key_delete.rb +0 -24
  83. data/lib/textus/cli/verb/list.rb +0 -16
  84. data/lib/textus/cli/verb/migrate.rb +0 -18
  85. data/lib/textus/cli/verb/mv.rb +0 -27
  86. data/lib/textus/cli/verb/propose.rb +0 -28
  87. data/lib/textus/cli/verb/published.rb +0 -13
  88. data/lib/textus/cli/verb/pulse.rb +0 -26
  89. data/lib/textus/cli/verb/rdeps.rb +0 -14
  90. data/lib/textus/cli/verb/reject.rb +0 -16
  91. data/lib/textus/cli/verb/retain.rb +0 -19
  92. data/lib/textus/cli/verb/rule_explain.rb +0 -16
  93. data/lib/textus/cli/verb/rule_lint.rb +0 -18
  94. data/lib/textus/cli/verb/rule_list.rb +0 -29
  95. data/lib/textus/cli/verb/schema.rb +0 -15
  96. data/lib/textus/cli/verb/uid.rb +0 -15
  97. data/lib/textus/cli/verb/where.rb +0 -14
  98. data/lib/textus/cli/verb/zone_mv.rb +0 -19
  99. data/lib/textus/read/get_or_fetch.rb +0 -69
  100. data/lib/textus/read/policy_explain.rb +0 -46
  101. data/lib/textus/read/rules.rb +0 -25
data/lib/textus/boot.rb CHANGED
@@ -81,12 +81,11 @@ module Textus
81
81
  { "name" => "list" },
82
82
  { "name" => "get" },
83
83
  { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
84
- { "name" => "schema" },
84
+ { "name" => "schema", "summary" => "schema operations: 'schema show KEY', 'schema diff', 'schema init', 'schema migrate'" },
85
85
  { "name" => "put" },
86
86
  { "name" => "propose" },
87
87
  { "name" => "accept", "summary" => "apply a queued proposal to its target zone; requires the author capability" },
88
- { "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
89
- { "name" => "delete", "summary" => "delete an entry; --as=<role>" },
88
+ { "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
90
89
  { "name" => "build", "summary" => "materialize derived entries; publish_to and publish_tree fan out copies" },
91
90
  { "name" => "fetch" },
92
91
  { "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
@@ -96,6 +95,7 @@ module Textus
96
95
  { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
97
96
  { "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
98
97
  { "name" => "pulse" },
98
+ { "name" => "capabilities" },
99
99
  ].freeze
100
100
 
101
101
  # Build the CLI verb catalog by deriving each summary from the corresponding
@@ -131,7 +131,7 @@ module Textus
131
131
  # agent's real read and write surface, named as verbs the agent calls —
132
132
  # not CLI strings. read_verbs can neither advertise a verb the agent
133
133
  # cannot call (audit/freshness/doctor are CLI-only) nor omit one it can
134
- # (schema/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
134
+ # (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
135
135
  # framing (role is connection-resolved over MCP; there is no stdin).
136
136
  # writable_zones / propose_zone below carry the agent's write authority.
137
137
  "read_verbs" => Textus::MCP::Catalog.read_verbs,
@@ -5,8 +5,12 @@ module Textus
5
5
  module Builder
6
6
  module InjectMeta
7
7
  # Returns a new hash with _meta as the first key, per SPEC §6 ordering.
8
+ # Carries only deterministic provenance (`from`/`reduce`/`template`) — the
9
+ # volatile `generated_at` is deliberately NOT stamped, so the built
10
+ # artifact is content-addressed and a rebuild is a byte-for-byte no-op
11
+ # (ADR 0070). Build time lives out of the tracked artifact.
8
12
  def self.call(content_hash, mentry)
9
- meta = { "generated_at" => Time.now.utc.iso8601 }
13
+ meta = {}
10
14
  if mentry.is_a?(Textus::Manifest::Entry::Derived)
11
15
  src = mentry.source
12
16
  if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
@@ -23,35 +27,6 @@ module Textus
23
27
  end
24
28
  end
25
29
 
26
- # Replaces the freshly-stamped timestamp inside `new_bytes` with the
27
- # timestamp pulled from `old_bytes` (same format). Returns the rewritten
28
- # bytes, or nil if either side lacks a parseable timestamp.
29
- module IdempotentWrite
30
- def self.rewrite_with_prior_timestamp(new_bytes:, old_bytes:, format:)
31
- prior = extract_timestamp(old_bytes, format)
32
- fresh = extract_timestamp(new_bytes, format)
33
- return nil unless prior && fresh
34
- return new_bytes if prior == fresh
35
-
36
- new_bytes.sub(fresh, prior)
37
- end
38
-
39
- def self.extract_timestamp(bytes, format)
40
- case format
41
- when "markdown"
42
- parsed = Entry.for_format("markdown").parse(bytes)
43
- parsed.dig("_meta", "generated", "at")
44
- when "json", "yaml"
45
- parsed = Entry.for_format(format).parse(bytes)
46
- parsed.dig("_meta", "generated_at")
47
- else # rubocop:disable Style/EmptyElse
48
- nil
49
- end
50
- rescue Textus::BadFrontmatter
51
- nil
52
- end
53
- end
54
-
55
30
  module Pipeline
56
31
  Deps = Data.define(
57
32
  :manifest, :reader, :lister, :rpc, :template_loader, :transform_context, :inject_boot
@@ -95,18 +70,12 @@ module Textus
95
70
  target_path
96
71
  end
97
72
 
98
- def self.write_if_changed(target_path, bytes, format)
99
- if File.exist?(target_path)
100
- old_bytes = File.binread(target_path)
101
- if format == "text"
102
- return if old_bytes == bytes
103
- else
104
- rewritten = IdempotentWrite.rewrite_with_prior_timestamp(
105
- new_bytes: bytes, old_bytes: old_bytes, format: format,
106
- )
107
- return if rewritten && rewritten == old_bytes
108
- end
109
- end
73
+ # Built artifacts are content-addressed (no volatile timestamp, ADR 0070),
74
+ # so identity is plain byte-equality: skip the write when nothing changed.
75
+ # `format` is retained for signature stability across renderers.
76
+ def self.write_if_changed(target_path, bytes, _format)
77
+ return if File.exist?(target_path) && File.binread(target_path) == bytes
78
+
110
79
  File.binwrite(target_path, bytes)
111
80
  end
112
81
  end
@@ -1,5 +1,3 @@
1
- require "time"
2
-
3
1
  module Textus
4
2
  module Builder
5
3
  class Renderer
@@ -14,12 +12,10 @@ module Textus
14
12
  else
15
13
  []
16
14
  end
17
- frontmatter = {
18
- "generated" => {
19
- "at" => Time.now.utc.iso8601,
20
- "from" => from,
21
- },
22
- }
15
+ # Deterministic frontmatter only — `from` (the source keys), never a
16
+ # volatile `generated.at` (ADR 0070): the artifact is content-addressed
17
+ # so a rebuild is a byte-for-byte no-op and a revert never drifts.
18
+ frontmatter = { "generated" => { "from" => from } }
23
19
  Entry.for_format("markdown").serialize(meta: frontmatter, body: body)
24
20
  end
25
21
  end
@@ -5,9 +5,9 @@ module Textus
5
5
  command_name "fetch"
6
6
 
7
7
  def parse(argv)
8
- if argv.first == "stale"
8
+ if argv.first == "all"
9
9
  argv.shift
10
- @sub_klass = Verb::FetchStale
10
+ @sub_klass = Verb::FetchAll
11
11
  else
12
12
  @sub_klass = Verb::Fetch
13
13
  end
@@ -6,6 +6,7 @@ module Textus
6
6
  # `parent_group` is this group counts as a subcommand. Sorted
7
7
  # alphabetically by command_name for stable help output.
8
8
  def subcommands
9
+ Textus::CLI::Runner.install!
9
10
  Verb.descendants
10
11
  .select { |k| k.parent_group == self && k.command_name }
11
12
  .sort_by(&:command_name)
@@ -0,0 +1,187 @@
1
+ module Textus
2
+ class CLI
3
+ # Generates CLI::Verb (and CLI::Group) subclasses from per-verb contracts,
4
+ # so the CLI surface is a projection of the contract — the operator-facing
5
+ # mirror of MCP::Catalog (ADR 0063).
6
+ module Runner
7
+ # Subclassable base for contract-projected verbs. Carries the verb's
8
+ # contract (class attr `spec`) and the generic dispatch, exposing one
9
+ # overridable seam, #invoke, that defaults to the generic projection.
10
+ # Escape-hatch verbs subclass this and override #invoke to add behavior
11
+ # (suggestions, --stdin, BuildLock, multi-dispatch) WITHOUT restating the
12
+ # verb name — `spec.verb` remains the single source of dispatch.
13
+ class Base < Verb
14
+ class << self
15
+ attr_accessor :spec
16
+
17
+ # ADR 0064: derive the CLI command name from the contract's cli_leaf
18
+ # when not set explicitly, so an escape-hatch class never restates its
19
+ # own name. The reconciliation spec proves command_name == cli_leaf for
20
+ # every such class, so this is an equivalence, not a behavior change.
21
+ def command_name(name = nil)
22
+ return super if name
23
+
24
+ super() || spec&.cli_leaf
25
+ end
26
+ end
27
+
28
+ def spec = self.class.spec
29
+
30
+ def call(store)
31
+ invoke(store)
32
+ end
33
+
34
+ # Default: pure contract projection. Override in subclasses for behavior.
35
+ def invoke(store)
36
+ Runner.dispatch(self, store, spec)
37
+ end
38
+
39
+ def flag_values(s = spec)
40
+ s.args.reject(&:positional).each_with_object({}) do |a, h|
41
+ raw = respond_to?(a.name) ? public_send(a.name) : nil
42
+ next if raw.nil?
43
+
44
+ h[a.name] = Runner.coerce(a, raw)
45
+ end
46
+ end
47
+ end
48
+
49
+ module_function
50
+
51
+ # Normalize parsed CLI input into the uniform by-name inputs hash and
52
+ # dispatch through RoleScope's single bind+invoke site. A missing required
53
+ # arg becomes a UsageError phrased in the operator's command path (parity
54
+ # with the hand-written verbs).
55
+ def dispatch(verb_instance, store, spec)
56
+ inputs = Textus::Contract::Binder.inputs_from_ordered(
57
+ spec, verb_instance.positional, verb_instance.flag_values(spec)
58
+ )
59
+ inputs = inputs.merge(Textus::Contract::Sources.from_stdin(spec, verb_instance.stdin)) if spec.cli_stdin
60
+ inputs = Textus::Contract::Sources.acquire(spec, inputs)
61
+ inputs = apply_cli_defaults(spec, inputs)
62
+ scope = verb_instance.session_for(store)
63
+ begin
64
+ result = scope.dispatch_bound(spec.verb, inputs)
65
+ rescue Textus::Contract::MissingArgs => e
66
+ raise UsageError.new("#{spec.cli_path} requires #{e.missing.first.wire}")
67
+ end
68
+ verb_instance.emit(shape(spec, result, inputs))
69
+ end
70
+
71
+ # Fill CLI-specific defaults (cli_default:) for args the operator did not
72
+ # pass, where the CLI default diverges from the contract default the agent
73
+ # surfaces use — e.g. migrate/zone_mv apply by default on the CLI but plan
74
+ # by default for agents (ADR 0068). The divergence is legible in the
75
+ # contract, not hidden in a hand class.
76
+ def apply_cli_defaults(spec, inputs)
77
+ spec.args.each_with_object(inputs.dup) do |a, h|
78
+ next if a.cli_default == :__unset || h.key?(a.name)
79
+
80
+ h[a.name] = a.cli_default
81
+ end
82
+ end
83
+
84
+ # Shape the use-case result for the CLI wire via the verb's :cli view
85
+ # (falling back to the default view). The view is called uniformly as
86
+ # (result, inputs); an inputs-aware view echoes an input such as the key
87
+ # (ADR 0067).
88
+ def shape(spec, result, inputs)
89
+ Textus::Contract::View.render(spec, :cli, result, inputs)
90
+ end
91
+
92
+ # The default the CLI flag is generated against — `cli_default:` when the
93
+ # operator-facing default diverges from the contract default the agent
94
+ # surfaces use, else the contract `default`. This drives boolean flag
95
+ # polarity so a verb that applies-by-default on the CLI but plans-by-default
96
+ # for agents (migrate, zone_mv) gets a `--dry-run` flag, not `--no-dry-run`.
97
+ def effective_default(arg)
98
+ arg.cli_default == :__unset ? arg.default : arg.cli_default
99
+ end
100
+
101
+ def flagspec_for(arg)
102
+ wire = arg.wire.to_s.tr("_", "-")
103
+ if arg.type == :boolean
104
+ effective_default(arg) == true ? "--no-#{wire}" : "--#{wire}"
105
+ else
106
+ "--#{wire}=VALUE"
107
+ end
108
+ end
109
+
110
+ # NB: compare arg.type by equality, not `case`/`===` — `Integer === arg.type`
111
+ # is false when arg.type is the Integer *class* (it tests instance-of), so a
112
+ # `when Integer` branch would silently never coerce.
113
+ def coerce(arg, raw)
114
+ return effective_default(arg) != true if arg.type == :boolean
115
+ return Integer(raw) if arg.type == Integer
116
+
117
+ raw
118
+ end
119
+
120
+ def ensure_group(name)
121
+ const = name.split("_").map(&:capitalize).join
122
+ return Group.const_get(const, false) if Group.const_defined?(const, false)
123
+
124
+ g = Class.new(Group) { command_name name }
125
+ Group.const_set(const, g)
126
+ g
127
+ end
128
+
129
+ # Contract verbs whose CLI behavior is a genuine `< Runner::Base` override
130
+ # — behavior the generic projection cannot express (ADR 0068/0069):
131
+ # get — raises UnknownKey with resolver suggestions (a CLI-only
132
+ # affordance; the agent surface deliberately returns nil)
133
+ # put — IntakeFetch read-through orchestration on --fetch
134
+ # build — auto-resolves the build-capability actor role (not --as) and
135
+ # serializes under BuildLock; the role resolution is policy, not
136
+ # a projection (around: covers only the lock)
137
+ BEHAVIORAL_HATCHES = %i[get put build].freeze
138
+
139
+ # Contract verbs whose CLI is a plain `< Verb` command, not a projection at
140
+ # all — worker verbs and composite reports assembled outside the contract:
141
+ # fetch, fetch_all — background intake workers (not request/response)
142
+ # boot, doctor — composite reports
143
+ NON_PROJECTED_CLI = %i[fetch fetch_all boot doctor].freeze
144
+
145
+ # The installer skips generation for either category.
146
+ HAND_AUTHORED_VERBS = (BEHAVIORAL_HATCHES + NON_PROJECTED_CLI).freeze
147
+
148
+ def hand_authored?(verb) = HAND_AUTHORED_VERBS.include?(verb)
149
+
150
+ def install!
151
+ @installed ||= {}
152
+ Textus::Dispatcher::VERBS.each_value do |klass|
153
+ next unless klass.respond_to?(:contract?) && klass.contract?
154
+
155
+ spec = klass.contract
156
+ next unless spec.cli?
157
+ next if hand_authored?(spec.verb)
158
+ next if @installed[spec.verb]
159
+
160
+ install_for(spec)
161
+ @installed[spec.verb] = true
162
+ end
163
+ end
164
+
165
+ def install_for(spec)
166
+ group = spec.cli_group ? ensure_group(spec.cli_group) : nil
167
+ leaf = spec.cli_leaf
168
+ non_positional = spec.args.reject(&:positional)
169
+
170
+ klass = Class.new(Base)
171
+ klass.spec = spec
172
+ klass.command_name leaf
173
+ klass.parent_group group if group
174
+ klass.option :as_flag, "--as=ROLE"
175
+ klass.option :use_stdin, "--stdin" if spec.cli_stdin
176
+ non_positional.each { |a| klass.option a.name, Runner.flagspec_for(a) }
177
+
178
+ # Anchor the anonymous class to a constant so descendants discovery is
179
+ # stable. Name it after the verb under a Generated namespace.
180
+ const_name = spec.verb.to_s.split("_").map(&:capitalize).join
181
+ gen = "Gen#{const_name}"
182
+ Verb.const_set(gen, klass) unless Verb.const_defined?(gen, false)
183
+ klass
184
+ end
185
+ end
186
+ end
187
+ end
@@ -1,12 +1,12 @@
1
1
  module Textus
2
2
  class CLI
3
3
  class Verb
4
- class Build < Verb
5
- command_name "build"
4
+ class Build < Runner::Base
5
+ self.spec = Textus::Write::Build.contract
6
6
 
7
7
  option :prefix, "--prefix=K"
8
8
 
9
- def call(store)
9
+ def invoke(store)
10
10
  role = store.manifest.policy.actor_for("build") or
11
11
  raise UsageError.new(
12
12
  "no role holds the 'build' capability",
@@ -14,7 +14,7 @@ module Textus
14
14
  )
15
15
  Textus::Ports::BuildLock.with(root: store.root) do
16
16
  ops = store.as(role)
17
- result = ops.publish(prefix: prefix)
17
+ result = ops.build(prefix: prefix)
18
18
  emit(result)
19
19
  end
20
20
  end
@@ -1,8 +1,8 @@
1
1
  module Textus
2
2
  class CLI
3
3
  class Verb
4
- class FetchStale < Verb
5
- command_name "stale"
4
+ class FetchAll < Verb
5
+ command_name "all"
6
6
  parent_group Group::Fetch
7
7
 
8
8
  option :prefix, "--prefix=KEY"
@@ -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
@@ -27,13 +33,21 @@ module Textus
27
33
  end
28
34
 
29
35
  def run(argv)
36
+ # `--root` is a global, position-agnostic option: pull it out of argv
37
+ # wherever it appears so it works uniformly before OR after any verb or
38
+ # group (e.g. both `textus --root=X hook list` and
39
+ # `textus hook list --root=X`). Without this, `order!` below only sees
40
+ # options before the first verb token, so a trailing `--root` reached the
41
+ # verb's own parser and raised InvalidOption (#161 F5). TEXTUS_ROOT already
42
+ # works everywhere via Store.discover, so this brings the flag to parity.
43
+ @root_arg = extract_root!(argv)
44
+
30
45
  # Define --version/--help ourselves so OptionParser doesn't intercept them
31
46
  # with its built-in handlers (which print "version unknown" and a bare usage
32
47
  # line, then exit before we ever reach the verb dispatch below).
33
48
  show_version = false
34
49
  show_help = false
35
50
  OptionParser.new do |o|
36
- o.on("--root=PATH") { |v| @root_arg = v }
37
51
  o.on("--version", "-v") { show_version = true }
38
52
  o.on("--help", "-h") { show_help = true }
39
53
  end.order!(argv)
@@ -52,6 +66,26 @@ module Textus
52
66
 
53
67
  private
54
68
 
69
+ # Remove the first `--root=PATH` or `--root PATH` token from argv (anywhere)
70
+ # and return its value, or nil if absent. Mutates argv in place.
71
+ def extract_root!(argv)
72
+ i = argv.index { |a| a == "--root" || a.start_with?("--root=") }
73
+ return nil unless i
74
+
75
+ tok = argv[i]
76
+ if tok.start_with?("--root=")
77
+ argv.delete_at(i)
78
+ tok.delete_prefix("--root=")
79
+ else
80
+ val = argv[i + 1]
81
+ raise UsageError.new("--root requires a PATH") if val.nil? || val.start_with?("-")
82
+
83
+ argv.delete_at(i + 1)
84
+ argv.delete_at(i)
85
+ val
86
+ end
87
+ end
88
+
55
89
  def coerce_exit_code(value)
56
90
  case value
57
91
  when Integer then value
@@ -91,7 +125,7 @@ module Textus
91
125
  textus put KEY --stdin [--fetch=NAME] --as=ROLE
92
126
  textus freshness [--prefix=KEY] [--zone=Z]
93
127
  textus fetch KEY
94
- textus fetch stale [--prefix=KEY] [--zone=Z]
128
+ textus fetch all [--prefix=KEY] [--zone=Z]
95
129
  textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
96
130
  textus blame KEY [--limit=N]
97
131
  textus doctor
@@ -1,22 +1,10 @@
1
1
  module Textus
2
2
  # Single capability record handed to every use case. Replaces the
3
- # ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store.
3
+ # ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store
4
+ # (see Store#initialize); Store delegates its readers to this record,
5
+ # so this `Data.define` is the single source of truth for the field set.
4
6
  Container = Data.define(
5
7
  :manifest, :file_store, :schemas, :root,
6
8
  :audit_log, :events, :rpc
7
9
  )
8
-
9
- class Container
10
- def self.from_store(store)
11
- new(
12
- manifest: store.manifest,
13
- file_store: store.file_store,
14
- schemas: store.schemas,
15
- root: store.root,
16
- audit_log: store.audit_log,
17
- events: store.events,
18
- rpc: store.rpc,
19
- )
20
- end
21
- end
22
10
  end
@@ -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