textus 0.43.1 → 0.43.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c26661afb6c59f813ccdb737e56666be242a14e3315a100348dd8514a41e78a
4
- data.tar.gz: 26ff3e7e8cd94a545cdcabe964c6e8c7311fe0179a24a13b0ab298eab7f2bf34
3
+ metadata.gz: d352b5ae1e1274a454c6488497c8bad60659b9c4f70ceb86da822f883b1559c5
4
+ data.tar.gz: fca73540d82d4a0e7ae9d527bdae43451c8a13a89f0da486e049232754a6b6d4
5
5
  SHA512:
6
- metadata.gz: db6b8047ba7d7c79ad78a653944709d7cc7cfd5c22a4baae116e97c8d6fea48c409788c835e1c1f83c7684a58da089b08a0525a44791de92875d1d2bb0c26a76
7
- data.tar.gz: 26e9d74398110f4d34dd59c837901170e5d80a847268f5edee45010f4793c51e1f47737d5d3c85b0098fa06b1334e00bf1038330a12a1061c9957d045e07638c
6
+ metadata.gz: 7559e8f9b1c49a2ddbf0a3bf83c5653fa951f3f575a53113274a3b1f857fabfb709649720d6137963f9b411aa87b1178f296ae4a9f907963250920e28a77880e
7
+ data.tar.gz: 60dea75e32ccdae818a6da8a4303ff7e14fd248e4230e0b7f54af45c24ea9539eaf0103707820e006e1e0ed925c030179b8e3483eb889a5120eb67afd030e9f7
data/CHANGELOG.md CHANGED
@@ -9,6 +9,19 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
9
9
  bump is a breaking change that requires a store migration; the gem version
10
10
  tracks both additive improvements and breaking protocol bumps independently.
11
11
 
12
+ ## 0.43.2 — 2026-06-02 — Agent-legible MCP contracts ([ADR 0057](docs/architecture/decisions/0057-agent-legible-mcp-contracts.md))
13
+
14
+ No `textus/3` wire-format change. One breaking (pre-1.0) change on the MCP transport — `put`/`propose` take frontmatter under `_meta` (was `meta`) — plus per-argument descriptions on every MCP tool and a fully catalog-derived agent verb surface.
15
+
16
+ ### Changed
17
+
18
+ - **BREAKING (pre-1.0): MCP `put`/`propose` take frontmatter under `_meta`, not `meta` ([ADR 0057](docs/architecture/decisions/0057-agent-legible-mcp-contracts.md)).** Every *read* returns the envelope under `_meta` (`get`, SPEC §8), and the CLI `--stdin` envelope already speaks `_meta` — only the MCP `put`/`propose` argument diverged as `meta`, breaking the natural read → edit → write round-trip. The contract gains a `wire_name` primitive so the wire speaks `_meta` while the use-case keeps its `meta:` kwarg (the ADR 0039 signature guard still reconciles). An MCP client that hardcoded the `meta` argument renames one key. The Ruby API (`store.put(meta:)`) and the CLI `--stdin` envelope are unchanged.
19
+ - **`boot.agent_quickstart.write_verbs` derives from the MCP catalog ([ADR 0057](docs/architecture/decisions/0057-agent-legible-mcp-contracts.md)).** It now returns bare verb names (`put propose fetch fetch_all`) like `read_verbs`, instead of the CLI string `"put KEY --as=agent --stdin"` (`--as`/`--stdin` are meaningless on an MCP connection — role is connection-resolved, ADR 0040). Closes the de-CLI follow-up named in ADR 0056; a symmetric guard spec fails the build if a CLI string creeps back.
20
+
21
+ ### Added
22
+
23
+ - **Per-argument descriptions on every MCP tool ([ADR 0057](docs/architecture/decisions/0057-agent-legible-mcp-contracts.md)).** All MCP-surfaced verb arguments now carry a one-line `description:` in their `tools/list` `inputSchema` (was 2 of ~30) — including `put`'s `body`/`content` mutual-exclusivity and the maintenance `dry_run` "default false applies immediately" gotcha. They ship once in `tools/list` at no per-call cost, so an agent can form a valid call from the schema alone.
24
+
12
25
  ## 0.43.1 — 2026-06-02 — `boot` agent surface derives from the MCP catalog ([ADR 0056](docs/architecture/decisions/0056-boot-quickstart-speaks-the-mcp-catalog.md))
13
26
 
14
27
  No `textus/3` wire-format change — `boot`'s agent-orientation fields are corrected to match the verbs an MCP agent can actually call.
data/lib/textus/boot.rb CHANGED
@@ -127,11 +127,15 @@ module Textus
127
127
  propose_zone = manifest.policy.propose_zone_for(agent_role)
128
128
 
129
129
  {
130
- # Derived from the MCP catalog (ADR 0056): the agent's real read surface,
131
- # so the quickstart can neither advertise a verb the agent cannot call
132
- # (audit/freshness/doctor are CLI-only) nor omit one it can (schema/rules).
130
+ # Both verb lists derive from the MCP catalog (ADR 0056, ADR 0057): the
131
+ # agent's real read and write surface, named as verbs the agent calls
132
+ # not CLI strings. read_verbs can neither advertise a verb the agent
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
135
+ # framing (role is connection-resolved over MCP; there is no stdin).
136
+ # writable_zones / propose_zone below carry the agent's write authority.
133
137
  "read_verbs" => Textus::MCP::Catalog.read_verbs,
134
- "write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
138
+ "write_verbs" => agent_role ? Textus::MCP::Catalog.write_verbs : [],
135
139
  "writable_zones" => writable_zones,
136
140
  "propose_zone" => propose_zone,
137
141
  "latest_seq" => audit_log.latest_seq,
@@ -8,7 +8,14 @@ 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
+ Arg = Data.define(:name, :type, :required, :positional, :session_default, :description, :wire_name) do
16
+ # The name used on the wire (defaults to the kwarg name).
17
+ def wire = wire_name || name
18
+ end
12
19
 
13
20
  JSON_TYPES = {
14
21
  String => "string", Integer => "integer", Hash => "object",
@@ -31,9 +38,9 @@ module Textus
31
38
  props = args.to_h do |a|
32
39
  h = { "type" => Contract.json_type(a.type) }
33
40
  h["description"] = a.description if a.description
34
- [a.name.to_s, h]
41
+ [a.wire.to_s, h]
35
42
  end
36
- { type: "object", properties: props, required: required_args.map { |a| a.name.to_s } }
43
+ { type: "object", properties: props, required: required_args.map { |a| a.wire.to_s } }
37
44
  end
38
45
  end
39
46
 
@@ -70,12 +77,13 @@ module Textus
70
77
  end
71
78
  end
72
79
 
73
- def arg(name, type, required: false, positional: false, session_default: nil, description: nil)
80
+ def arg(name, type, required: false, positional: false, session_default: nil, description: nil, wire_name: nil) # rubocop:disable Metrics/ParameterLists
74
81
  raise "contract already built; declare args before reading .contract" if defined?(@__contract) && @__contract
75
82
 
76
83
  (@__args ||= []) << Arg.new(
77
84
  name: name, type: type, required: required,
78
- positional: positional, session_default: session_default, description: description
85
+ positional: positional, session_default: session_default,
86
+ description: description, wire_name: wire_name
79
87
  )
80
88
  end
81
89
 
@@ -7,8 +7,8 @@ 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
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
12
  response(&:to_h)
13
13
 
14
14
  def initialize(container:, call:)
@@ -8,9 +8,9 @@ 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
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
14
  response(&:to_h)
15
15
 
16
16
  def initialize(container:, call:)
@@ -10,8 +10,9 @@ 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
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"
15
16
  response(&:to_h)
16
17
 
17
18
  def initialize(container:, call:)
@@ -11,7 +11,8 @@ 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
14
+ arg :candidate_yaml, String, required: true,
15
+ description: "manifest YAML; its `rules:` block is diffed against the live manifest (no writes)"
15
16
  response(&:to_h)
16
17
 
17
18
  def initialize(container:, call:)
@@ -11,9 +11,9 @@ 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
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
17
  response(&:to_h)
18
18
 
19
19
  def initialize(container:, call:)
@@ -35,6 +35,17 @@ module Textus
35
35
  .keys.map(&:to_s)
36
36
  end
37
37
 
38
+ # MCP-surfaced write verbs, by Dispatcher class namespace — the mirror of
39
+ # read_verbs for the write side. `boot.agent_quickstart.write_verbs` derives
40
+ # from this so it advertises bare verb names the agent can call (no `--as`/
41
+ # `--stdin` CLI framing), finishing the de-CLI-ing of the agent surface
42
+ # (ADR 0056, ADR 0057).
43
+ def write_verbs
44
+ Textus::Dispatcher::VERBS
45
+ .select { |_verb, klass| mcp_surfaced?(klass) && klass.name.start_with?("Textus::Write::") }
46
+ .keys.map(&:to_s)
47
+ end
48
+
38
49
  def mcp_surfaced?(klass)
39
50
  klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
40
51
  end
@@ -59,14 +70,14 @@ module Textus
59
70
  # the session when absent from the wire; they are never treated as missing.
60
71
  # Positional args are emitted in contract declaration order; use-case signatures must match.
61
72
  def map_args(spec, raw, session = nil)
62
- missing = spec.required_args.map { |a| a.name.to_s } - raw.keys
73
+ missing = spec.required_args.map { |a| a.wire.to_s } - raw.keys
63
74
  raise ToolError.new("#{spec.verb}: missing #{missing.join(", ")}") unless missing.empty?
64
75
 
65
76
  positional = []
66
77
  keyword = {}
67
78
  spec.args.each do |a|
68
- if raw.key?(a.name.to_s)
69
- value = raw[a.name.to_s]
79
+ if raw.key?(a.wire.to_s)
80
+ value = raw[a.wire.to_s]
70
81
  elsif a.session_default && session
71
82
  value = session.public_send(a.session_default)
72
83
  else
@@ -11,7 +11,8 @@ module Textus
11
11
  verb :get
12
12
  summary "Read one entry. Returns the envelope (uid, etag, _meta, body, freshness)."
13
13
  surfaces :cli, :ruby, :mcp
14
- arg :key, String, required: true, positional: true
14
+ arg :key, String, required: true, positional: true,
15
+ description: "dotted entry key to read, e.g. 'knowledge.project'"
15
16
  response(&:to_h_for_wire)
16
17
 
17
18
  def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
@@ -6,8 +6,8 @@ module Textus
6
6
  verb :list
7
7
  summary "List keys filtered by zone and/or prefix."
8
8
  surfaces :cli, :ruby, :mcp
9
- arg :prefix, String
10
- arg :zone, String
9
+ arg :prefix, String, description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.runbooks'"
10
+ arg :zone, String, description: "restrict to one zone by name (see `boot` zones); combine with prefix to narrow further"
11
11
 
12
12
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
13
13
  @manifest = container.manifest
@@ -9,7 +9,8 @@ module Textus
9
9
  verb :rules
10
10
  summary "Return effective rules for a key (fetch, guard, ...)."
11
11
  surfaces :ruby, :mcp
12
- arg :key, String, required: true, positional: true
12
+ arg :key, String, required: true, positional: true,
13
+ description: "dotted key whose effective rules you want (fetch ttl/action, write guard, ...)"
13
14
 
14
15
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
15
16
  @manifest = container.manifest
@@ -6,7 +6,8 @@ module Textus
6
6
  verb :schema
7
7
  summary "Return the schema (field shape) for an entry's family, by key."
8
8
  surfaces :ruby, :mcp
9
- arg :key, String, required: true, positional: true
9
+ arg :key, String, required: true, positional: true,
10
+ description: "any key in the family whose schema you want; returns required/optional fields and their types"
10
11
 
11
12
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
12
13
  @manifest = container.manifest
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.43.1"
2
+ VERSION = "0.43.2"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -6,8 +6,8 @@ module Textus
6
6
  verb :fetch_all
7
7
  summary "Fetch all stale quarantine entries, optionally scoped by zone/prefix."
8
8
  surfaces :cli, :ruby, :mcp
9
- arg :prefix, String
10
- arg :zone, String
9
+ arg :prefix, String, description: "only refresh stale entries whose key starts with this dotted prefix"
10
+ arg :zone, String, description: "only refresh stale entries in this quarantine zone (see `pulse` stale list)"
11
11
 
12
12
  def initialize(container:, call:)
13
13
  @container = container
@@ -8,7 +8,8 @@ module Textus
8
8
  verb :fetch
9
9
  summary "Run a fetch action for one quarantine entry."
10
10
  surfaces :cli, :ruby, :mcp
11
- arg :key, String, required: true, positional: true
11
+ arg :key, String, required: true, positional: true,
12
+ description: "quarantine-zone entry key to refresh using its declared intake action"
12
13
  response { |outcome| { "outcome" => outcome.class.name.split("::").last.downcase } }
13
14
 
14
15
  FETCH_TIMEOUT_SECONDS = IntakeFetch::FETCH_TIMEOUT_SECONDS
@@ -12,9 +12,12 @@ module Textus
12
12
  surfaces :cli, :ruby, :mcp
13
13
  arg :key, String, required: true, positional: true,
14
14
  description: "key relative to propose_zone, e.g. 'decisions.feature-x'"
15
- arg :meta, Hash, required: true
16
- arg :body, String
17
- arg :content, Hash
15
+ arg :meta, Hash, required: true, wire_name: :_meta,
16
+ description: "frontmatter; reads back as `_meta` from `get`. Include a 'proposal:' block naming the target_key"
17
+ arg :body, String,
18
+ description: "markdown/text payload for markdown-format entries; omit (use `content`) for json/yaml entries. Do not send both"
19
+ arg :content, Hash,
20
+ description: "structured payload for json/yaml-format entries; omit (use `body`) for markdown entries. Do not send both"
18
21
  response { |env| { "uid" => env.uid, "etag" => env.etag, "key" => env.key } }
19
22
 
20
23
  def initialize(container:, call:)
@@ -6,11 +6,16 @@ module Textus
6
6
  verb :put
7
7
  summary "Create or update an entry. Schema-validated. Returns {uid, etag}."
8
8
  surfaces :cli, :ruby, :mcp
9
- arg :key, String, required: true, positional: true
10
- arg :meta, Hash, required: true
11
- arg :body, String
12
- arg :content, Hash
13
- arg :if_etag, String
9
+ arg :key, String, required: true, positional: true,
10
+ description: "dotted entry key, e.g. 'knowledge.project'; must resolve to a zone the role may write"
11
+ arg :meta, Hash, required: true, wire_name: :_meta,
12
+ description: "frontmatter; reads back as `_meta` from `get`. Schema-validated — call `schema KEY` first"
13
+ arg :body, String,
14
+ description: "markdown/text payload for markdown-format entries; omit (use `content`) for json/yaml entries. Do not send both"
15
+ arg :content, Hash,
16
+ description: "structured payload for json/yaml-format entries; omit (use `body`) for markdown entries. Do not send both"
17
+ arg :if_etag, String,
18
+ description: "optimistic-concurrency guard: the etag you last read; the write is rejected if the entry changed since"
14
19
  response { |env| { "uid" => env.uid, "etag" => env.etag } }
15
20
 
16
21
  def initialize(container:, call:)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.43.1
4
+ version: 0.43.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick