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
@@ -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
@@ -12,7 +12,14 @@ module Textus
12
12
  # envelope key) when it must differ from the use-case kwarg `name` — e.g. `put`
13
13
  # takes the `meta:` kwarg but exposes `_meta` on the wire to match what `get`
14
14
  # returns and what the CLI `--stdin` envelope already speaks (ADR 0057).
15
- Arg = Data.define(:name, :type, :required, :positional, :session_default, :description, :wire_name) do
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
16
23
  # The name used on the wire (defaults to the kwarg name).
17
24
  def wire = wire_name || name
18
25
  end
@@ -26,8 +33,21 @@ module Textus
26
33
  JSON_TYPES.fetch(type) { raise ArgumentError.new("no JSON type mapping for #{type.inspect}") }
27
34
  end
28
35
 
29
- Spec = Data.define(:verb, :summary, :args, :surfaces, :response) do
36
+ Spec = Data.define(:verb, :summary, :args, :surfaces, :views, :cli, :around, :cli_stdin) do
30
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
31
51
 
32
52
  def required_args = args.select(&:required)
33
53
 
@@ -77,19 +97,56 @@ module Textus
77
97
  end
78
98
  end
79
99
 
80
- def arg(name, type, required: false, positional: false, session_default: nil, description: nil, wire_name: nil) # rubocop:disable Metrics/ParameterLists
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
81
121
  raise "contract already built; declare args before reading .contract" if defined?(@__contract) && @__contract
82
122
 
83
123
  (@__args ||= []) << Arg.new(
84
124
  name: name, type: type, required: required,
85
125
  positional: positional, session_default: session_default,
86
- description: description, wire_name: wire_name
126
+ description: description, wire_name: wire_name, default: default,
127
+ source: source, coerce: coerce, cli_default: cli_default
87
128
  )
88
129
  end
89
130
 
90
- def response(&blk)
91
- @__response = blk if blk
92
- @__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
93
150
  end
94
151
 
95
152
  def contract?
@@ -105,7 +162,10 @@ module Textus
105
162
  summary: @__summary,
106
163
  args: (@__args || []).freeze,
107
164
  surfaces: (@__surfaces || []).freeze,
108
- response: response,
165
+ views: ((@__views ||= {})[:default] ||= ->(v, _i) { v }) && @__views,
166
+ cli: @__cli,
167
+ around: @__around,
168
+ cli_stdin: @__cli_stdin,
109
169
  )
110
170
  end
111
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,15 @@ 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,
39
+ capabilities: Textus::Read::Capabilities,
40
40
 
41
41
  # Maintenance
42
42
  migrate: Textus::Maintenance::Migrate,
@@ -8,7 +8,7 @@ module Textus
8
8
  # that drift without making `build` scan globally.
9
9
  class OrphanedPublishTargets < Check
10
10
  def call
11
- sdir = File.join(root, Textus::Ports::SentinelStore::DIR)
11
+ sdir = Textus::Layout.sentinels(root)
12
12
  return [] unless File.directory?(sdir)
13
13
 
14
14
  repo_root = File.dirname(root)
@@ -5,7 +5,7 @@ module Textus
5
5
  def call
6
6
  store = Textus::Ports::SentinelStore.new
7
7
  file_stat = Textus::Ports::Storage::FileStat.new
8
- dir = File.join(root, "sentinels")
8
+ dir = Textus::Layout.sentinels(root)
9
9
  return [] unless file_stat.directory?(dir)
10
10
 
11
11
  repo_root = File.dirname(root)
@@ -35,13 +35,14 @@ module Textus
35
35
  private
36
36
 
37
37
  # Domain-pure: reads the stored write timestamp from the envelope's
38
- # freshness (checked_at) or meta (last_fetched_at/generated_at) and
39
- # parses the stored ISO-8601 string. Parsing a stored string is not
40
- # I/O (allowed in domain, ADR 0024).
38
+ # freshness (checked_at) or meta (last_fetched_at) and parses the
39
+ # stored ISO-8601 string. Parsing a stored string is not I/O (allowed
40
+ # in domain, ADR 0024). `generated_at` is intentionally NOT consulted:
41
+ # build-generation time is no longer carried in the artifact (ADR
42
+ # 0070), and fetch-freshness is a fetch concept, not a build one.
41
43
  def written_at(envelope)
42
44
  raw = envelope.freshness&.checked_at ||
43
- envelope.meta&.dig("last_fetched_at") ||
44
- envelope.meta&.dig("generated_at")
45
+ envelope.meta&.dig("last_fetched_at")
45
46
  return raw if raw.is_a?(Time)
46
47
  return nil if raw.nil?
47
48
 
@@ -82,6 +82,7 @@ module Textus
82
82
  raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
83
83
 
84
84
  @file_store.delete(path)
85
+ prune_empty_parents(path)
85
86
  @audit_log.append(
86
87
  role: @call.role, verb: "delete", key: key,
87
88
  etag_before: etag_before, etag_after: nil,
@@ -99,6 +100,7 @@ module Textus
99
100
 
100
101
  FileUtils.mkdir_p(File.dirname(to_path))
101
102
  FileUtils.mv(from_path, to_path)
103
+ prune_empty_parents(from_path)
102
104
  basename = to_key.split(".").last
103
105
  Entry.for_format(new_mentry.format).rewrite_name(to_path, basename)
104
106
  etag_after = Etag.for_file(to_path)
@@ -129,6 +131,38 @@ module Textus
129
131
 
130
132
  private
131
133
 
134
+ # After a file leaves a directory (delete or move-source), remove any
135
+ # now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
136
+ # (F3 of #161). Floored at the entry's *zone directory* — a zone is a
137
+ # declared, first-class container, so its own dir is preserved even when
138
+ # momentarily empty; only the sub-dirs the bulk op carved out are
139
+ # pruned. Stops at the first non-empty ancestor, so a dir holding a
140
+ # `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
141
+ # non-empty dir is silently fine, never fatal to the write.
142
+ def prune_empty_parents(path)
143
+ floor = zone_floor(path)
144
+ return unless floor
145
+
146
+ dir = File.dirname(path)
147
+ while dir.start_with?("#{floor}/") && Dir.empty?(dir)
148
+ Dir.rmdir(dir)
149
+ dir = File.dirname(dir)
150
+ end
151
+ rescue SystemCallError
152
+ nil
153
+ end
154
+
155
+ # The zone directory under which `path` lives (`<root>/zones/<zone>`),
156
+ # or nil if `path` is not under the store's zones tree.
157
+ def zone_floor(path)
158
+ zones_root = File.join(@manifest.data.root, "zones")
159
+ prefix = "#{zones_root}/"
160
+ return nil unless path.start_with?(prefix)
161
+
162
+ zone_seg = path.delete_prefix(prefix).split("/").first
163
+ zone_seg && File.join(zones_root, zone_seg)
164
+ end
165
+
132
166
  def ensure_uid(format, meta, content, existing_uid)
133
167
  Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
134
168
  end
@@ -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
data/lib/textus/layout.rb CHANGED
@@ -29,6 +29,14 @@ module Textus
29
29
  File.join(run(root), "audit")
30
30
  end
31
31
 
32
+ # Sentinels are machine-generated (the published target's sha), not authored
33
+ # source, so they live on the runtime side under `.run/` — git-ignored,
34
+ # regenerated by the next build via content-identical adoption (ADR 0070,
35
+ # superseding ADR 0038's `:config` classification).
36
+ def self.sentinels(root)
37
+ File.join(run(root), "sentinels")
38
+ end
39
+
32
40
  def self.audit_log(root)
33
41
  File.join(audit_dir(root), "audit.log")
34
42
  end
@@ -6,17 +6,20 @@ module Textus
6
6
 
7
7
  verb :key_delete_prefix
8
8
  summary "Bulk-delete every leaf key under prefix."
9
- surfaces :cli, :ruby, :mcp
10
- arg :prefix, String, required: true, description: "every leaf key under this dotted prefix is deleted"
11
- arg :dry_run, :boolean, description: "true returns the Plan without writing; default false applies the delete immediately"
12
- response(&:to_h)
9
+ surfaces :cli, :mcp
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: false,
13
+ description: "when true, returns the keys that would be deleted without deleting them; " \
14
+ "defaults to false, so omitting it deletes immediately"
15
+ view { |v, _i| v.to_h }
13
16
 
14
17
  def initialize(container:, call:)
15
18
  @container = container
16
19
  @call = call
17
20
  end
18
21
 
19
- def call(prefix:, dry_run: false)
22
+ def call(prefix, dry_run: false)
20
23
  raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
21
24
 
22
25
  leaves = Read::List.new(container: @container)
@@ -7,21 +7,33 @@ module Textus
7
7
 
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
- surfaces :cli, :ruby, :mcp
11
- arg :from_prefix, String, required: true, description: "dotted prefix whose leaf keys are renamed"
12
- arg :to_prefix, String, required: true, description: "dotted prefix the keys are renamed to"
13
- arg :dry_run, :boolean, description: "true returns the Plan without writing; default false applies the rename immediately"
14
- response(&:to_h)
10
+ surfaces :cli, :mcp
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: false,
15
+ description: "when true, returns the planned moves without applying them; " \
16
+ "defaults to false, so omitting it applies the rename immediately"
17
+ view { |v, _i| v.to_h }
15
18
 
16
19
  def initialize(container:, call:)
17
20
  @container = container
18
21
  @call = call
19
22
  end
20
23
 
21
- def call(from_prefix:, to_prefix:, dry_run: false)
24
+ def call(from_prefix, to_prefix, dry_run: false)
22
25
  raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
23
26
 
24
27
  leaves = list_leaves_under(from_prefix)
28
+
29
+ # When from_prefix is itself a leaf, `delete_prefix("#{from_prefix}.")`
30
+ # finds no trailing dot to strip, so the tail keeps the whole key and the
31
+ # move silently targets "to_prefix.<full-from_prefix>". Refuse it — a
32
+ # single-key rename is `mv`'s job, not the bulk prefix verb's.
33
+ if leaves.include?(from_prefix)
34
+ raise UsageError.new("from_prefix '#{from_prefix}' is itself a leaf — use `mv` to rename a single key")
35
+ end
36
+
25
37
  warnings = []
26
38
  warnings << "no keys under #{from_prefix}" if leaves.empty?
27
39
 
@@ -9,18 +9,20 @@ module Textus
9
9
 
10
10
  verb :migrate
11
11
  summary "Run a YAML migration plan (multi-op)."
12
- surfaces :cli, :ruby, :mcp
13
- arg :plan_yaml, String, required: true,
14
- description: "YAML listing the migration ops (zone_mv, key_mv_prefix, key_delete_prefix) run in order"
15
- arg :dry_run, :boolean, description: "true returns the combined plan without writing; default false applies every op immediately"
16
- response(&:to_h)
12
+ surfaces :cli, :mcp
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: false,
16
+ description: "when true, returns the planned ops without applying them; " \
17
+ "defaults to false, so omitting it runs the migration immediately"
18
+ view { |v, _i| v.to_h }
17
19
 
18
20
  def initialize(container:, call:)
19
21
  @container = container
20
22
  @call = call
21
23
  end
22
24
 
23
- def call(plan_yaml:, dry_run: false)
25
+ def call(plan_yaml, dry_run: false)
24
26
  raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
25
27
  raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
26
28
 
@@ -41,11 +43,13 @@ module Textus
41
43
  private
42
44
 
43
45
  def invoke_op(op_name, op_hash, dry_run:)
44
- kwargs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
45
46
  klass = op_class(op_name)
46
- klass.new(
47
- container: @container, call: @call,
48
- ).call(**kwargs)
47
+ inputs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
48
+ # Each op now carries positional args (from/to, from_prefix/to_prefix,
49
+ # prefix); split the YAML fields into (positional, keyword) via the op's
50
+ # own contract so we call its #call signature correctly (ADR 0066/0068).
51
+ args, kwargs = Textus::Contract::Binder.bind(klass.contract, inputs)
52
+ klass.new(container: @container, call: @call).call(*args, **kwargs)
49
53
  end
50
54
 
51
55
  def op_class(op_name)
@@ -10,10 +10,11 @@ module Textus
10
10
 
11
11
  verb :rule_lint
12
12
  summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
13
- surfaces :cli, :ruby, :mcp
14
- arg :candidate_yaml, String, required: true,
15
- description: "manifest YAML; its `rules:` block is diffed against the live manifest (no writes)"
16
- response(&:to_h)
13
+ surfaces :cli, :mcp
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 }
17
18
 
18
19
  def initialize(container:, call:)
19
20
  @container = container
@@ -10,11 +10,14 @@ module Textus
10
10
 
11
11
  verb :zone_mv
12
12
  summary "Rename a zone — manifest + files. Refuses if destination exists."
13
- surfaces :cli, :ruby, :mcp
14
- arg :from, String, required: true, description: "current zone name"
15
- arg :to, String, required: true, description: "new zone name; refused if a zone by this name already exists"
16
- arg :dry_run, :boolean, description: "true returns the plan without writing; default false applies the rename immediately"
17
- response(&:to_h)
13
+ surfaces :cli, :mcp
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: false,
18
+ description: "when true, returns the planned zone move without applying it; " \
19
+ "defaults to false, so omitting it applies the move immediately"
20
+ view { |v, _i| v.to_h }
18
21
 
19
22
  def initialize(container:, call:)
20
23
  @container = container
@@ -23,7 +26,7 @@ module Textus
23
26
  @root = container.root
24
27
  end
25
28
 
26
- def call(from:, to:, dry_run: false)
29
+ def call(from, to, dry_run: false)
27
30
  raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
28
31
  raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
29
32
 
@@ -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
@@ -4,7 +4,7 @@ module Textus
4
4
  # (ADR 0039). `tool_schemas` feeds tools/list; `call` is the generic
5
5
  # tools/call dispatch: map JSON args -> (positional, keyword) per the
6
6
  # contract, invoke the verb through the role scope, then shape the
7
- # return value with the contract's response block. No per-tool code.
7
+ # return value with the contract's default view. No per-tool code.
8
8
  module Catalog
9
9
  module_function
10
10
 
@@ -55,43 +55,16 @@ module Textus
55
55
  raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
56
56
 
57
57
  spec = klass.contract
58
- pos, kw = map_args(spec, args || {}, session)
59
- result = store.as(session.role).public_send(spec.verb, *pos, **kw)
60
- spec.response.call(result)
58
+ inputs = Textus::Contract::Binder.inputs_from_wire(spec, args)
59
+ result = store.as(session.role).dispatch_bound(spec.verb, inputs, session: session)
60
+ Textus::Contract::View.render(spec, :default, result, inputs)
61
+ rescue Textus::Contract::MissingArgs => e
62
+ raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
61
63
  rescue ContractDrift, CursorExpired
62
64
  raise
63
65
  rescue Textus::Error => e
64
66
  raise ToolError.new("#{name}: #{e.message}")
65
67
  end
66
-
67
- # Splits the raw JSON arg hash into the positional list and keyword hash
68
- # the use-case expects, validating required presence first.
69
- # Session-default args (session_default: :method_name) are injected from
70
- # the session when absent from the wire; they are never treated as missing.
71
- # Positional args are emitted in contract declaration order; use-case signatures must match.
72
- def map_args(spec, raw, session = nil)
73
- missing = spec.required_args.map { |a| a.wire.to_s } - raw.keys
74
- raise ToolError.new("#{spec.verb}: missing #{missing.join(", ")}") unless missing.empty?
75
-
76
- positional = []
77
- keyword = {}
78
- spec.args.each do |a|
79
- if raw.key?(a.wire.to_s)
80
- value = raw[a.wire.to_s]
81
- elsif a.session_default && session
82
- value = session.public_send(a.session_default)
83
- else
84
- next
85
- end
86
-
87
- if a.positional
88
- positional << value
89
- else
90
- keyword[a.name] = value
91
- end
92
- end
93
- [positional, keyword]
94
- end
95
68
  end
96
69
  end
97
70
  end
@@ -7,8 +7,9 @@ module Textus
7
7
  # artifact; no parsing or stripping.
8
8
  #
9
9
  # Sentinel I/O is delegated to Textus::Ports::SentinelStore. Sentinels live
10
- # under `<store_root>/sentinels/` and mirror the target's repo-relative layout
11
- # so consumer directories aren't polluted with `.textus-managed.json` siblings.
10
+ # under `<store_root>/.run/sentinels/` (runtime, git-ignored ADR 0070) and
11
+ # mirror the target's repo-relative layout so consumer directories aren't
12
+ # polluted with `.textus-managed.json` siblings.
12
13
  module Publisher
13
14
  def self.publish(source:, target:, store_root:)
14
15
  FileUtils.mkdir_p(File.dirname(target))