textus 0.35.1 → 0.39.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -2
  3. data/README.md +34 -13
  4. data/SPEC.md +10 -4
  5. data/lib/textus/boot.rb +41 -21
  6. data/lib/textus/cli/verb/mcp_serve.rb +8 -3
  7. data/lib/textus/cli/verb/propose.rb +28 -0
  8. data/lib/textus/cli/verb/pulse.rb +12 -3
  9. data/lib/textus/cli/verb/schema.rb +1 -1
  10. data/lib/textus/cli/verb.rb +3 -2
  11. data/lib/textus/contract.rb +106 -0
  12. data/lib/textus/cursor_store.rb +24 -0
  13. data/lib/textus/dispatcher.rb +3 -1
  14. data/lib/textus/doctor/check/audit_log.rb +1 -1
  15. data/lib/textus/doctor/check/fetch_locks.rb +2 -2
  16. data/lib/textus/doctor/check/illegal_keys.rb +10 -4
  17. data/lib/textus/domain/policy/evaluation.rb +3 -6
  18. data/lib/textus/init.rb +4 -0
  19. data/lib/textus/layout.rb +41 -0
  20. data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
  21. data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
  22. data/lib/textus/maintenance/migrate.rb +9 -0
  23. data/lib/textus/maintenance/rule_lint.rb +8 -0
  24. data/lib/textus/maintenance/zone_mv.rb +10 -0
  25. data/lib/textus/manifest/entry/base.rb +5 -0
  26. data/lib/textus/manifest/entry/ignore_matcher.rb +46 -0
  27. data/lib/textus/manifest/entry/nested.rb +9 -2
  28. data/lib/textus/manifest/entry/validators/ignore.rb +28 -0
  29. data/lib/textus/manifest/entry/validators.rb +1 -0
  30. data/lib/textus/manifest/resolver.rb +2 -0
  31. data/lib/textus/manifest/schema.rb +1 -1
  32. data/lib/textus/mcp/catalog.rb +72 -0
  33. data/lib/textus/mcp/server.rb +8 -5
  34. data/lib/textus/mcp/session.rb +3 -20
  35. data/lib/textus/mcp/tool_schemas.rb +6 -62
  36. data/lib/textus/mcp/tools.rb +4 -119
  37. data/lib/textus/ports/audit_log.rb +17 -15
  38. data/lib/textus/ports/build_lock.rb +1 -2
  39. data/lib/textus/ports/fetch/lock.rb +1 -1
  40. data/lib/textus/read/audit.rb +3 -3
  41. data/lib/textus/read/boot.rb +6 -0
  42. data/lib/textus/read/get.rb +8 -0
  43. data/lib/textus/read/list.rb +8 -0
  44. data/lib/textus/read/pulse.rb +7 -0
  45. data/lib/textus/read/rules.rb +24 -0
  46. data/lib/textus/read/schema_envelope.rb +7 -0
  47. data/lib/textus/role.rb +6 -2
  48. data/lib/textus/session.rb +24 -0
  49. data/lib/textus/store.rb +11 -0
  50. data/lib/textus/version.rb +1 -1
  51. data/lib/textus/write/accept.rb +1 -1
  52. data/lib/textus/write/delete.rb +1 -1
  53. data/lib/textus/write/fetch_all.rb +8 -0
  54. data/lib/textus/write/fetch_worker.rb +9 -1
  55. data/lib/textus/write/mv.rb +1 -1
  56. data/lib/textus/write/propose.rb +46 -0
  57. data/lib/textus/write/put.rb +13 -1
  58. data/lib/textus/write/reject.rb +1 -1
  59. data/lib/textus.rb +4 -0
  60. metadata +15 -5
  61. data/docs/conventions.md +0 -148
@@ -0,0 +1,24 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ # Per-role cursor cache under <root>/.run/state/cursor.<role>. A convenience so
5
+ # `textus pulse` (no --since) means "since I last looked". Gitignored;
6
+ # losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
7
+ class CursorStore
8
+ def initialize(root:, role:)
9
+ @path = Textus::Layout.cursor(root, role)
10
+ end
11
+
12
+ def read
13
+ Integer(File.read(@path).strip)
14
+ rescue Errno::ENOENT, ArgumentError
15
+ 0
16
+ end
17
+
18
+ def write(seq)
19
+ FileUtils.mkdir_p(File.dirname(@path))
20
+ File.write(@path, seq.to_s)
21
+ seq
22
+ end
23
+ end
24
+ end
@@ -6,6 +6,7 @@ module Textus
6
6
  VERBS = {
7
7
  # Write
8
8
  put: Textus::Write::Put,
9
+ propose: Textus::Write::Propose,
9
10
  delete: Textus::Write::Delete,
10
11
  mv: Textus::Write::Mv,
11
12
  accept: Textus::Write::Accept,
@@ -30,11 +31,12 @@ module Textus
30
31
  pulse: Textus::Read::Pulse,
31
32
  policy_explain: Textus::Read::PolicyExplain,
32
33
  published: Textus::Read::Published,
33
- schema_envelope: Textus::Read::SchemaEnvelope,
34
+ schema: Textus::Read::SchemaEnvelope,
34
35
  validate_all: Textus::Read::ValidateAll,
35
36
  doctor: Textus::Read::Doctor,
36
37
  boot: Textus::Read::Boot,
37
38
  retainable: Textus::Read::Retainable,
39
+ rules: Textus::Read::Rules,
38
40
 
39
41
  # Maintenance
40
42
  migrate: Textus::Maintenance::Migrate,
@@ -3,7 +3,7 @@ module Textus
3
3
  class Check
4
4
  class AuditLog < Check
5
5
  def call
6
- path = File.join(root, "audit.log")
6
+ path = Textus::Layout.audit_log(root)
7
7
  Textus::Ports::AuditLog.new(root).verify_integrity.map do |v|
8
8
  {
9
9
  "code" => "audit.parse_error",
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  module Doctor
3
3
  class Check
4
- # Lists per-key fetch lock files under <root>/.locks/ whose
4
+ # Lists per-key fetch lock files under <root>/.run/locks/ whose
5
5
  # recorded PID is no longer running. These are forensic artifacts only:
6
6
  # Fetch::Lock uses flock(2), which the kernel releases on process
7
7
  # death, so stale files do not block subsequent acquires. The check
@@ -9,7 +9,7 @@ module Textus
9
9
  # (e.g. a fetch path that crashes repeatedly).
10
10
  class FetchLocks < Check
11
11
  def call
12
- dir = File.join(root, ".locks")
12
+ dir = Textus::Layout.locks(root)
13
13
  return [] unless File.directory?(dir)
14
14
 
15
15
  Dir.glob(File.join(dir, "*.lock")).filter_map { |path| inspect_lock(path) }
@@ -11,15 +11,18 @@ module Textus
11
11
  next unless File.directory?(base)
12
12
 
13
13
  index_fn = entry.respond_to?(:index_filename) ? entry.index_filename : nil
14
- index_fn ? check_index_paths(entry, index_fn, base, out) : check_all_paths(base, out)
14
+ index_fn ? check_index_paths(entry, index_fn, base, out) : check_all_paths(entry, base, out)
15
15
  end
16
16
  out
17
17
  end
18
18
 
19
19
  private
20
20
 
21
- def check_all_paths(base, out)
21
+ def check_all_paths(entry, base, out)
22
22
  walk_nested(base) do |abs_path, is_dir|
23
+ rel = abs_path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
24
+ next if entry.ignored?(rel)
25
+
23
26
  basename = File.basename(abs_path)
24
27
  stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
25
28
  next if stem.match?(Key::Grammar::SEGMENT)
@@ -31,10 +34,13 @@ module Textus
31
34
  # When the entry uses `index_filename:`, only the parent-directory
32
35
  # segments leading to each index file participate in keys. Sibling
33
36
  # files and unrelated subtrees are not enumerated and must not be
34
- # flagged. Each illegal segment is reported once per path.
35
- def check_index_paths(_entry, index_fn, base, out)
37
+ # flagged. Each illegal segment is reported once per path. Paths under
38
+ # an ignored subtree (ADR 0042) are excluded before any segment check.
39
+ def check_index_paths(entry, index_fn, base, out)
36
40
  Dir.glob(File.join(base, "**", index_fn)).each do |fp|
37
41
  rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
42
+ next if entry.ignored?(rel)
43
+
38
44
  File.dirname(rel).split("/").reject { |s| s.empty? || s == "." }.each do |seg|
39
45
  next if seg.match?(Key::Grammar::SEGMENT)
40
46
 
@@ -3,16 +3,13 @@
3
3
  module Textus
4
4
  module Domain
5
5
  module Policy
6
- # Immutable context handed to every predicate. `snapshot` is the
6
+ # Immutable context handed to every predicate. `manifest` is the
7
7
  # manifest (pure, no I/O); `envelope` is the entry under evaluation
8
8
  # (nil when no bytes exist yet, e.g. a fresh put). `origin`/`target`
9
9
  # are dotted keys; `transition` is the verb symbol.
10
10
  Evaluation = Data.define(
11
- :actor, :transition, :origin, :target, :envelope, :snapshot
12
- ) do
13
- def manifest = snapshot
14
- def role = actor
15
- end
11
+ :actor, :transition, :origin, :target, :envelope, :manifest
12
+ )
16
13
  end
17
14
  end
18
15
  end
data/lib/textus/init.rb CHANGED
@@ -92,6 +92,10 @@ module Textus
92
92
  end
93
93
  File.write(File.join(target_root, "hooks", "README.md"), HOOKS_README)
94
94
  File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
95
+ FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
96
+ FileUtils.mkdir_p(Textus::Layout.state(target_root))
97
+ FileUtils.mkdir_p(Textus::Layout.locks(target_root))
98
+ File.write(File.join(target_root, ".gitignore"), Textus::Layout::GITIGNORE)
95
99
  { "protocol" => PROTOCOL, "initialized" => target_root }
96
100
  end
97
101
  end
@@ -0,0 +1,41 @@
1
+ module Textus
2
+ # Single source of truth for every path textus owns under a store root.
3
+ # All disposable runtime state nests under <root>/.run/ so the
4
+ # tracked/disposable boundary is a directory boundary. ADR 0038.
5
+ module Layout
6
+ RUN = ".run"
7
+
8
+ def self.run(root)
9
+ File.join(root, RUN)
10
+ end
11
+
12
+ def self.state(root)
13
+ File.join(run(root), "state")
14
+ end
15
+
16
+ def self.cursor(root, role)
17
+ File.join(state(root), "cursor.#{role}")
18
+ end
19
+
20
+ def self.locks(root)
21
+ File.join(run(root), "locks")
22
+ end
23
+
24
+ def self.build_lock(root)
25
+ File.join(run(root), "build.lock")
26
+ end
27
+
28
+ def self.audit_dir(root)
29
+ File.join(run(root), "audit")
30
+ end
31
+
32
+ def self.audit_log(root)
33
+ File.join(audit_dir(root), "audit.log")
34
+ end
35
+
36
+ GITIGNORE = <<~GITIGNORE
37
+ # textus runtime artifacts — safe to delete, never commit
38
+ #{RUN}/
39
+ GITIGNORE
40
+ end
41
+ end
@@ -2,6 +2,15 @@ module Textus
2
2
  module Maintenance
3
3
  # Bulk-delete every leaf key under `prefix`.
4
4
  class KeyDeletePrefix
5
+ extend Textus::Contract::DSL
6
+
7
+ verb :key_delete_prefix
8
+ summary "Bulk-delete every leaf key under prefix."
9
+ surfaces :cli, :ruby, :mcp
10
+ arg :prefix, String, required: true
11
+ arg :dry_run, :boolean
12
+ response(&:to_h)
13
+
5
14
  def initialize(container:, call:)
6
15
  @container = container
7
16
  @call = call
@@ -3,6 +3,16 @@ module Textus
3
3
  # Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
4
4
  # Calls Write::Mv directly for each entry — emits one audit row per file moved.
5
5
  class KeyMvPrefix
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :key_mv_prefix
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
12
+ arg :to_prefix, String, required: true
13
+ arg :dry_run, :boolean
14
+ response(&:to_h)
15
+
6
16
  def initialize(container:, call:)
7
17
  @container = container
8
18
  @call = call
@@ -5,6 +5,15 @@ module Textus
5
5
  # Loads a YAML migration plan and dispatches each op to the
6
6
  # appropriate Maintenance use case. Concatenates resulting Plans.
7
7
  class Migrate
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :migrate
11
+ summary "Run a YAML migration plan (multi-op)."
12
+ surfaces :cli, :ruby, :mcp
13
+ arg :plan_yaml, String, required: true
14
+ arg :dry_run, :boolean
15
+ response(&:to_h)
16
+
8
17
  def initialize(container:, call:)
9
18
  @container = container
10
19
  @call = call
@@ -6,6 +6,14 @@ module Textus
6
6
  # YAML string. Returns a Plan describing rule additions/removals/
7
7
  # changes. Does NOT write anything.
8
8
  class RuleLint
9
+ extend Textus::Contract::DSL
10
+
11
+ verb :rule_lint
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
+ response(&:to_h)
16
+
9
17
  def initialize(container:, call:)
10
18
  @container = container
11
19
  @call = call
@@ -6,6 +6,16 @@ module Textus
6
6
  # the `zone:` field on every entry under the old zone, and moves
7
7
  # every file from zones/<old>/ to zones/<new>/.
8
8
  class ZoneMv
9
+ extend Textus::Contract::DSL
10
+
11
+ verb :zone_mv
12
+ summary "Rename a zone — manifest + files. Refuses if destination exists."
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)
18
+
9
19
  def initialize(container:, call:)
10
20
  @container = container
11
21
  @call = call
@@ -39,6 +39,11 @@ module Textus
39
39
  def events = {}
40
40
  def publish_each = nil
41
41
  def index_filename = nil
42
+ def ignore = []
43
+
44
+ # Per-entry ignore (ADR 0042). Base entries enumerate no tree, so
45
+ # nothing is ever ignored; Nested overrides with real patterns.
46
+ def ignored?(_rel_path) = false
42
47
 
43
48
  # Minimal context object passed into entry `publish_via` hooks.
44
49
  # Everything beyond the three primitives is derived. Data.define
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ # Pure glob matcher backing per-entry `ignore:` patterns (ADR 0042).
5
+ # `rel_path` is the slash-joined path of a candidate file relative to the
6
+ # entry's base directory.
7
+ #
8
+ # Matching is segment-wise so the `**` globstar means "zero or more path
9
+ # segments" — `File.fnmatch` alone cannot express this (under
10
+ # FNM_PATHNAME a trailing `**` will not cross a `/`; without it a leading
11
+ # `**/` will not match zero leading segments). So `**/node_modules/**`
12
+ # catches the `node_modules` subtree at any depth, including the store
13
+ # root, and the directory entry itself.
14
+ #
15
+ # Within a single segment, matching delegates to `File.fnmatch` with
16
+ # FNM_EXTGLOB, so a single `*` is anchored to that segment (it does not
17
+ # cross `/`) and `{a,b}` alternation works.
18
+ module IgnoreMatcher
19
+ SEGMENT_FLAGS = File::FNM_EXTGLOB
20
+
21
+ def self.match?(patterns, rel_path)
22
+ path_segs = rel_path.split("/").reject(&:empty?)
23
+ Array(patterns).any? do |pat|
24
+ match_segments(pat.split("/").reject(&:empty?), path_segs)
25
+ end
26
+ end
27
+
28
+ # Classic globstar matcher. `**` matches zero or more whole segments;
29
+ # any other pattern segment matches exactly one path segment via fnmatch.
30
+ def self.match_segments(pat_segs, path_segs)
31
+ return path_segs.empty? if pat_segs.empty?
32
+
33
+ if pat_segs.first == "**"
34
+ match_segments(pat_segs[1..], path_segs) ||
35
+ (!path_segs.empty? && match_segments(pat_segs, path_segs[1..]))
36
+ else
37
+ !path_segs.empty? &&
38
+ File.fnmatch?(pat_segs.first, path_segs.first, SEGMENT_FLAGS) &&
39
+ match_segments(pat_segs[1..], path_segs[1..])
40
+ end
41
+ end
42
+ private_class_method :match_segments
43
+ end
44
+ end
45
+ end
46
+ end
@@ -5,16 +5,22 @@ module Textus
5
5
  PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
6
6
  PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
7
7
 
8
- attr_reader :index_filename, :publish_each
8
+ attr_reader :index_filename, :publish_each, :ignore
9
9
 
10
- def initialize(index_filename: nil, publish_each: nil, **rest)
10
+ def initialize(index_filename: nil, publish_each: nil, ignore: nil, **rest)
11
11
  super(**rest)
12
12
  @index_filename = index_filename
13
13
  @publish_each = publish_each
14
+ @ignore = Array(ignore)
14
15
  end
15
16
 
16
17
  def nested? = true
17
18
 
19
+ # True when `rel_path` (slash-joined, relative to the entry base) matches
20
+ # any configured ignore glob. Evaluated ABOVE key-legality (ADR 0042):
21
+ # an ignored path is excluded, never judged.
22
+ def ignored?(rel_path) = IgnoreMatcher.match?(@ignore, rel_path)
23
+
18
24
  def publish_target_for(full_key)
19
25
  return nil if @publish_each.nil?
20
26
 
@@ -65,6 +71,7 @@ module Textus
65
71
  new(
66
72
  index_filename: raw["index_filename"],
67
73
  publish_each: raw["publish_each"],
74
+ ignore: raw["ignore"],
68
75
  **common,
69
76
  )
70
77
  end
@@ -0,0 +1,28 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ # Validates the per-entry `ignore:` field (ADR 0042): a list of
6
+ # non-empty glob strings, allowed only on nested entries.
7
+ module Ignore
8
+ def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
9
+ patterns = entry.raw["ignore"]
10
+ return if patterns.nil?
11
+
12
+ raise UsageError.new("entry '#{entry.key}': ignore requires nested: true") unless entry.nested?
13
+
14
+ raise UsageError.new("entry '#{entry.key}': ignore must be a list of glob strings") unless patterns.is_a?(Array)
15
+
16
+ patterns.each do |pat|
17
+ next if pat.is_a?(String) && !pat.empty?
18
+
19
+ raise UsageError.new(
20
+ "entry '#{entry.key}': each ignore pattern must be a non-empty string (got #{pat.inspect})",
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -7,6 +7,7 @@ module Textus
7
7
  PublishEach,
8
8
  InjectBoot,
9
9
  IndexFilename,
10
+ Ignore,
10
11
  FormatMatrix,
11
12
  ].freeze
12
13
 
@@ -78,6 +78,8 @@ module Textus
78
78
 
79
79
  def nested_row_for(entry, base, path)
80
80
  rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
81
+ return nil if entry.ignored?(rel)
82
+
81
83
  entry_if = entry.index_filename
82
84
  stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
83
85
  segs = stripped.split("/").reject { |s| s.empty? || s == "." }
@@ -25,7 +25,7 @@ module Textus
25
25
  ENTRY_KEYS = %w[
26
26
  key path zone kind schema owner nested format
27
27
  compute template publish_to publish_each
28
- intake events inject_boot index_filename
28
+ intake events inject_boot index_filename ignore
29
29
  ].freeze
30
30
  COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
31
31
  INTAKE_KEYS = %w[handler config].freeze
@@ -0,0 +1,72 @@
1
+ module Textus
2
+ module MCP
3
+ # Derives the entire MCP tool surface from the per-verb contracts
4
+ # (ADR 0039). `tool_schemas` feeds tools/list; `call` is the generic
5
+ # tools/call dispatch: map JSON args -> (positional, keyword) per the
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.
8
+ module Catalog
9
+ module_function
10
+
11
+ # Contracts of every MCP-surfaced verb, in Dispatcher order.
12
+ def specs
13
+ Textus::Dispatcher::VERBS.values
14
+ .select { |k| k.respond_to?(:contract?) && k.contract? && k.contract.mcp? }
15
+ .map(&:contract)
16
+ end
17
+
18
+ def tool_schemas
19
+ specs.map do |s|
20
+ { name: s.verb.to_s, description: s.summary, inputSchema: s.input_schema }
21
+ end.freeze
22
+ end
23
+
24
+ def names
25
+ specs.map { |s| s.verb.to_s }
26
+ end
27
+
28
+ def call(name, session:, store:, args:)
29
+ klass = Textus::Dispatcher::VERBS[name.to_sym]
30
+ raise ToolError.new("unknown tool: #{name}") unless klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
31
+
32
+ spec = klass.contract
33
+ pos, kw = map_args(spec, args || {}, session)
34
+ result = store.as(session.role).public_send(spec.verb, *pos, **kw)
35
+ spec.response.call(result)
36
+ rescue ContractDrift, CursorExpired
37
+ raise
38
+ rescue Textus::Error => e
39
+ raise ToolError.new("#{name}: #{e.message}")
40
+ end
41
+
42
+ # Splits the raw JSON arg hash into the positional list and keyword hash
43
+ # the use-case expects, validating required presence first.
44
+ # Session-default args (session_default: :method_name) are injected from
45
+ # the session when absent from the wire; they are never treated as missing.
46
+ # Positional args are emitted in contract declaration order; use-case signatures must match.
47
+ def map_args(spec, raw, session = nil)
48
+ missing = spec.required_args.map { |a| a.name.to_s } - raw.keys
49
+ raise ToolError.new("#{spec.verb}: missing #{missing.join(", ")}") unless missing.empty?
50
+
51
+ positional = []
52
+ keyword = {}
53
+ spec.args.each do |a|
54
+ if raw.key?(a.name.to_s)
55
+ value = raw[a.name.to_s]
56
+ elsif a.session_default && session
57
+ value = session.public_send(a.session_default)
58
+ else
59
+ next
60
+ end
61
+
62
+ if a.positional
63
+ positional << value
64
+ else
65
+ keyword[a.name] = value
66
+ end
67
+ end
68
+ [positional, keyword]
69
+ end
70
+ end
71
+ end
72
+ end
@@ -49,8 +49,11 @@ module Textus
49
49
  end
50
50
 
51
51
  def handle_initialize(rid, _params)
52
- proposer = @store.manifest.policy.proposer_role
53
- propose_zone = @store.manifest.policy.propose_zone_for(proposer)
52
+ # The acting role IS the resolved connection role (ADR 0040): the MCP
53
+ # transport defaults to `agent`, which can write the queue, so its
54
+ # propose_zone resolves directly. If a connection's role cannot propose,
55
+ # propose_zone is nil and the `propose` tool reports that honestly.
56
+ propose_zone = @store.manifest.policy.propose_zone_for(@role)
54
57
 
55
58
  @session = Session.new(
56
59
  role: @role,
@@ -67,7 +70,7 @@ module Textus
67
70
  end
68
71
 
69
72
  def handle_tools_list(rid)
70
- emit_result(rid, { "tools" => ToolSchemas.all })
73
+ emit_result(rid, { "tools" => Catalog.tool_schemas })
71
74
  end
72
75
 
73
76
  def handle_tools_call(rid, params)
@@ -80,8 +83,8 @@ module Textus
80
83
 
81
84
  name = params["name"]
82
85
  args = params["arguments"] || {}
83
- result = Tools.call(name, session: @session, store: @store, args: args)
84
- @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "tick"
86
+ result = Catalog.call(name, session: @session, store: @store, args: args)
87
+ @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
85
88
 
86
89
  emit_result(rid, {
87
90
  "content" => [{ "type" => "text", "text" => JSON.dump(result) }],
@@ -1,24 +1,7 @@
1
1
  module Textus
2
2
  module MCP
3
- # Per-connection state held by the server. Immutable Data value;
4
- # advance_cursor returns a new instance via #with.
5
- Session = Data.define(:role, :cursor, :propose_zone, :manifest_etag) do
6
- def advance_cursor(new_cursor) = with(cursor: new_cursor)
7
-
8
- def check_etag!(observed_etag)
9
- return if observed_etag == manifest_etag
10
-
11
- raise ContractDrift.new(
12
- "manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
13
- )
14
- end
15
-
16
- private
17
-
18
- # First 8 hex chars after the "sha256:" prefix — a stable short id for
19
- # the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
20
- # a no-op when the prefix is absent).
21
- def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
22
- end
3
+ # The session value now lives in core (ADR 0036); retained here as an
4
+ # alias so existing MCP references keep resolving.
5
+ Session = Textus::Session
23
6
  end
24
7
  end
@@ -1,70 +1,14 @@
1
1
  module Textus
2
2
  module MCP
3
- # JSON-Schema definitions for every MCP tool's inputSchema. Returned by
4
- # the server in tools/list. Static today a follow-up will enrich with
5
- # manifest-derived enums for `zone`, `key`, etc.
3
+ # Kept for name stability (ADR 0039). The JSON schemas are DERIVED from
4
+ # per-verb contracts; this delegates to MCP::Catalog. The hand-written
5
+ # array is gone a kwarg rename now updates the schema automatically (and
6
+ # the signature guard fails if the contract lags the use-case).
6
7
  module ToolSchemas
7
8
  module_function
8
9
 
9
- def all # rubocop:disable Metrics/MethodLength
10
- [
11
- tool("boot", "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart.", {}, []),
12
- tool("tick", "Delta since cursor. Returns {cursor, changed, stale, pending_review, doctor}.",
13
- { "since" => { "type" => "integer", "minimum" => 0 } }, []),
14
- tool("find", "List keys filtered by zone and/or prefix.",
15
- { "zone" => { "type" => "string" }, "prefix" => { "type" => "string" } }, []),
16
- tool("read", "Read one entry. Returns the envelope (uid, etag, _meta, body, freshness).",
17
- { "key" => { "type" => "string" } }, ["key"]),
18
- tool("write", "Create or update an entry. Schema-validated. Returns {uid, etag}.",
19
- {
20
- "key" => { "type" => "string" },
21
- "meta" => { "type" => "object" },
22
- "body" => { "type" => "string" },
23
- "content" => { "type" => "object" },
24
- "if_etag" => { "type" => "string" },
25
- }, %w[key meta]),
26
- tool("propose", "Write a proposal to the session's propose_zone. Auto-prefixes the key.",
27
- {
28
- "key" => { "type" => "string", "description" => "Key relative to propose_zone, e.g. 'proposal.feature-x'" },
29
- "meta" => { "type" => "object" },
30
- "body" => { "type" => "string" },
31
- }, %w[key meta]),
32
- tool("fetch", "Run an intake fetch for one key. Returns the fetch Outcome.",
33
- { "key" => { "type" => "string" } }, ["key"]),
34
- tool("fetch_stale", "Fetch all stale intake entries, optionally scoped by zone/prefix.",
35
- {
36
- "zone" => { "type" => "string" },
37
- "prefix" => { "type" => "string" },
38
- }, []),
39
- tool("schema", "Return the schema (field shape) for an entry family.",
40
- { "family" => { "type" => "string" } }, ["family"]),
41
- tool("rules", "Return effective rules for a key (fetch, promote, ...).",
42
- { "key" => { "type" => "string" } }, ["key"]),
43
- tool("key_mv_prefix",
44
- "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false.",
45
- { "from_prefix" => { "type" => "string" }, "to_prefix" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
46
- %w[from_prefix to_prefix]),
47
- tool("key_delete_prefix", "Bulk-delete every leaf key under prefix.",
48
- { "prefix" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
49
- ["prefix"]),
50
- tool("zone_mv", "Rename a zone — manifest + files. Refuses if destination exists.",
51
- { "from" => { "type" => "string" }, "to" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
52
- %w[from to]),
53
- tool("rule_lint", "Diff candidate manifest YAML's rules against the live manifest. No writes.",
54
- { "candidate_yaml" => { "type" => "string" } },
55
- ["candidate_yaml"]),
56
- tool("migrate", "Run a YAML migration plan (multi-op).",
57
- { "plan_yaml" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
58
- ["plan_yaml"]),
59
- ].freeze
60
- end
61
-
62
- def tool(name, description, properties, required)
63
- {
64
- name: name,
65
- description: description,
66
- inputSchema: { type: "object", properties: properties, required: required },
67
- }
10
+ def all
11
+ Catalog.tool_schemas
68
12
  end
69
13
  end
70
14
  end