Module: Textus::Boot

Defined in:
lib/textus/boot.rb

Overview

Read-only “what’s in this store and how do I use it” envelope. A single call gives an agent the working model of a textus-managed project: zones and their write authority, entries and their flags, registered hooks, write flows, and the CLI verb catalog.

Boot is side-effect-free.

Constant Summary collapse

PROTOCOL_ID =
PROTOCOL
WRITE_FLOW_TEMPLATES =

Per-capability write-flow templates. Each lambda receives the user-facing role name and the manifest, and returns guidance for that verb with the live zone named by kind (ADR 0034). A role holding multiple verbs gets one joined string; roles whose verbs have no template are omitted.

{
  author: lambda do |name, manifest|
    "edit files in #{zone_label(manifest, :canon, "your canon zone")}, " \
      "then 'textus put KEY --as=#{name}'"
  end,
  keep: lambda do |name, manifest|
    "keep durable notes in #{zone_label(manifest, :workspace, "your workspace")}: " \
      "'textus put KEY --as=#{name}' (no accept needed)"
  end,
  propose: lambda do |name, manifest|
    authority = manifest.policy.roles_with_capability("author").first || "the author-holder"
    "propose changes by writing #{manifest.policy.queue_zone}.* entries with --as=#{name} " \
      "and a 'proposal:' frontmatter block; the #{authority} role runs 'textus accept' to apply"
  end,
  reconcile: lambda do |_name, manifest|
    machine = zone_label(manifest, :machine, "machine")
    "'textus reconcile' materializes derived #{machine} entries from their sources and " \
      "refreshes stale intake #{machine} entries from their declared source; " \
      "derived files are never hand-edited (reactive on canon writes, or a full pass on demand)"
  end,
}.freeze
AGENT_PROTOCOL_TEMPLATE =

Static, store-independent parts of the agent-facing protocol. The ‘recipes` and `role_resolution` blocks are derived per-manifest in agent_protocol(…) because zone and role names are user-configurable.

{
  "envelope_shape" => {
    "summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
    "fields" => {
      "_meta" => "hash of structured frontmatter; schema-validated per entry family",
      "body" => "string payload (markdown/text) or nil for json/yaml formats where body lives in _meta",
      "uid" => "stable 16-char hex identifier; preserved across writes and key renames",
      "etag" => "content hash; pass back on writes to detect concurrent edits",
    },
    "ref" => "SPEC.md §8",
  },
}.freeze
CURATED_CLI_VERBS =

Curated agent-facing verb catalog. This declares which verbs the operator CLI surfaces and in what order — the editorial presentation. The summary of each verb is a fact, not presentation: it is derived from ‘contract.summary` at load time (ADR 0039). A literal “summary” survives here only for grouped CLI tokens (schema/key/rule/hook) that aggregate several sub-contracts and so have no single contract to derive from. CLI_VERBS itself is assigned in textus.rb after Zeitwerk eager_load so all contract files are present.

[
  { "name" => "boot" },
  { "name" => "list" },
  { "name" => "get" },
  { "name" => "where" },
  { "name" => "schema", "summary" => "schema operations: 'schema show KEY', 'schema diff', 'schema init', 'schema migrate'" },
  { "name" => "put" },
  { "name" => "propose" },
  { "name" => "accept" },
  { "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
  { "name" => "reconcile" },
  { "name" => "audit" },
  { "name" => "blame" },
  { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
  { "name" => "doctor" },
  { "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
  { "name" => "pulse" },
  { "name" => "capabilities" },
].freeze
CLI_VERBS =

Derive CLI_VERBS after eager_load so all contract-declaring files are present (boot.rb loads first alphabetically; Dispatcher contracts are declared later).

Textus::Boot.build_cli_verbs.freeze

Class Method Summary collapse

Class Method Details

.agent_protocol(manifest) ⇒ Object



185
186
187
188
189
190
191
192
193
194
195
# File 'lib/textus/boot.rb', line 185

def self.agent_protocol(manifest)
  AGENT_PROTOCOL_TEMPLATE.merge(
    "recipes" => recipes(manifest),
    "role_resolution" => {
      "summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
                   "then a transport default ('human' for CLI, 'agent' for MCP)",
      "roles" => manifest.data.role_caps.keys,
      "ref" => "SPEC.md §5",
    },
  )
end

.agent_quickstart(manifest, audit_log) ⇒ Object



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/textus/boot.rb', line 117

def self.agent_quickstart(manifest, audit_log)
  agent_role = manifest.policy.proposer_role

  writable_zones = manifest.data.declared_zone_kinds.keys.each_with_object([]) do |zname, acc|
    acc << zname if agent_role && manifest.policy.zone_writers(zname).include?(agent_role)
  end

  propose_zone = manifest.policy.propose_zone_for(agent_role)

  {
    # Both verb lists derive from the MCP catalog (ADR 0056, ADR 0057): the
    # agent's real read and write surface, named as verbs the agent calls —
    # not CLI strings. read_verbs can neither advertise a verb the agent
    # cannot call (audit/doctor are CLI-only; freshness is a Ruby-only
    # internal scan, ADR 0085) nor omit one it can
    # (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
    # framing (role is connection-resolved over MCP; there is no stdin).
    # writable_zones / propose_zone below carry the agent's write authority.
    "read_verbs" => Textus::MCP::Catalog.read_verbs,
    "write_verbs" => agent_role ? Textus::MCP::Catalog.write_verbs : [],
    "writable_zones" => writable_zones,
    "propose_zone" => propose_zone,
    "latest_seq" => audit_log.latest_seq,
  }
end

.build(container:, lean: false) ⇒ Object



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/textus/boot.rb', line 197

def self.build(container:, lean: false)
  manifest = container.manifest
  etag = Textus::Etag.for_contract(container.root)

  if lean
    return {
      "protocol" => PROTOCOL_ID,
      "store_root" => container.root,
      "zones" => zones_for(manifest),
      "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
      "contract_etag" => etag,
    }
  end

  {
    "protocol" => PROTOCOL_ID,
    "store_root" => container.root,
    "zones" => zones_for(manifest),
    "entries" => entries_for(manifest),
    "hooks" => hooks_for_container(container),
    "write_flows" => write_flows_for(manifest),
    "cli_verbs" => CLI_VERBS.map(&:dup),
    "agent_protocol" => agent_protocol(manifest),
    "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
    "contract_etag" => etag,
    "docs" => { "spec" => "SPEC.md", "example" => "examples/project/" },
  }
end

.build_cli_verbsObject

Build the CLI verb catalog: each summary is derived from its contract when one exists, falling back to the curated editorial string for grouped tokens (schema/key/rule/hook). Called once from textus.rb after eager_load.



109
110
111
112
113
114
115
# File 'lib/textus/boot.rb', line 109

def self.build_cli_verbs
  summaries = contract_summaries
  CURATED_CLI_VERBS.map do |entry|
    derived = summaries[entry["name"]]
    derived ? entry.merge("summary" => derived) : entry
  end
end

.contract_summariesObject

verb token => contract.summary, for every Dispatcher verb that carries a contract. The single source for a verb’s one-line summary (ADR 0039).



100
101
102
103
104
# File 'lib/textus/boot.rb', line 100

def self.contract_summaries
  Dispatcher::VERBS.values
                   .select { |k| k.respond_to?(:contract?) && k.contract? }
                   .to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
end

.entries_for(manifest) ⇒ Object



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/textus/boot.rb', line 237

def self.entries_for(manifest)
  manifest.data.entries.map do |e|
    derived = e.derived?
    {
      "key" => e.key,
      "zone" => e.zone,
      "schema" => e.schema,
      "nested" => e.is_a?(Textus::Manifest::Entry::Nested),
      "owner" => e.owner,
      "format" => e.format,
      "derived" => derived,
      "intake" => e.intake?,
      "publish_to" => Array(e.publish_to),
    }
  end
end

.hooks_for_container(container) ⇒ Object



254
255
256
# File 'lib/textus/boot.rb', line 254

def self.hooks_for_container(container)
  hooks_for_container_internal(rpc: container.rpc, events: container.events)
end

.hooks_for_container_internal(rpc:, events:) ⇒ Object



258
259
260
261
262
263
264
265
266
267
# File 'lib/textus/boot.rb', line 258

def self.hooks_for_container_internal(rpc:, events:)
  sections = {}
  Hooks::Catalog::RPC.each_key do |event|
    sections[event.to_s] = rpc.names(event).map(&:to_s).sort
  end
  Hooks::Catalog::PUBSUB.each_key do |event|
    sections[event.to_s] = events.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
  end
  sections
end

.recipes(manifest) ⇒ Object

Recipes reference verbs, not a transport’s CLI strings (ADR 0056): every step names a verb the agent can call (each transport frames it — CLI as ‘textus get KEY`, MCP as the `get` tool) or is a plain materialize step. This keeps shell lines out of the surface an MCP agent reads.



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/textus/boot.rb', line 147

def self.recipes(manifest)
  queue = manifest.policy.queue_zone
  feeds = zone_label(manifest, :machine, "the machine zone")
  {
    "read" => {
      "purpose" => "find and read an entry",
      "steps" => [
        "list (zone:, prefix:) — discover keys without reading bodies",
        "get KEY — returns the entry envelope",
      ],
    },
    "write" => {
      "purpose" => "create or update an entry",
      "steps" => [
        "schema KEY — learn the _meta field shape (required, optional, field types) before writing",
        "assemble an envelope: { _meta: {…}, body: \"…\" }",
        "put KEY — persist it (role-gated); pass if_etag to guard a concurrent edit",
      ],
    },
    "propose" => {
      "purpose" => "agent suggests a change for human review",
      "agent_steps" => [
        "propose KEY — writes the change into the #{queue} zone for review",
      ],
      "human_steps" => [
        "accept #{queue}.KEY — promotes the proposal into its target zone",
      ],
    },
    "reconcile" => {
      "purpose" => "keep the machine-maintained lanes fresh — re-pull stale intake entries from their declared source",
      "steps" => [
        "pulse — its `stale` list names entries past their ttl",
        "reconcile (zone: #{feeds}) — re-pull the stale entries",
      ],
    },
  }
end

.write_flows_for(manifest) ⇒ Object



37
38
39
40
41
42
43
44
45
# File 'lib/textus/boot.rb', line 37

def self.write_flows_for(manifest)
  manifest.data.role_caps.each_with_object({}) do |(name, caps), acc|
    flows = caps.filter_map do |verb|
      tmpl = WRITE_FLOW_TEMPLATES[verb.to_sym]
      tmpl&.call(name, manifest)
    end
    acc[name] = flows.join(" / ") unless flows.empty?
  end
end

.zone_label(manifest, kind, fallback) ⇒ Object

Human-readable name(s) for the live zone(s) of a given kind, or ‘fallback` when the manifest declares none. Lets write-flow guidance name the live zone by kind instead of a hardcoded instance name (ADR 0034).



50
51
52
53
# File 'lib/textus/boot.rb', line 50

def self.zone_label(manifest, kind, fallback)
  zones = manifest.policy.zones_of_kind(kind)
  zones.empty? ? fallback : zones.join(", ")
end

.zones_for(manifest) ⇒ Object



226
227
228
229
230
231
232
233
234
235
# File 'lib/textus/boot.rb', line 226

def self.zones_for(manifest)
  manifest.data.declared_zone_kinds.keys.map do |name|
    row = { "name" => name, "writers" => manifest.policy.zone_writers(name) }
    kind = manifest.policy.declared_kind(name)
    row["kind"] = kind.to_s if kind
    purpose = manifest.data.zone_descs[name]
    row["purpose"] = purpose if purpose && !purpose.empty?
    row
  end
end