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| = 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 #{} role runs 'textus accept' to apply" end, converge: lambda do |_name, manifest| machine = zone_label(manifest, :machine, "machine") "'textus drain' 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" => "enqueue" }, { "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" }, { "name" => "drain" }, { "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" => "jobs" }, { "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
- .agent_protocol(manifest) ⇒ Object
- .agent_quickstart(manifest, audit_log) ⇒ Object
- .build(container:, lean: false) ⇒ Object
-
.build_cli_verbs ⇒ Object
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).
-
.contract_summaries ⇒ Object
verb token => contract.summary, for every Dispatcher verb that carries a contract.
- .entries_for(manifest) ⇒ Object
- .hooks_for_container(container) ⇒ Object
- .hooks_for_container_internal(rpc:, events:) ⇒ Object
-
.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.
- .write_flows_for(manifest) ⇒ Object
-
.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.
- .zones_for(manifest) ⇒ Object
Class Method Details
.agent_protocol(manifest) ⇒ Object
187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/textus/boot.rb', line 187 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
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/textus/boot.rb', line 119 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
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 225 226 |
# File 'lib/textus/boot.rb', line 199 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_verbs ⇒ Object
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.
111 112 113 114 115 116 117 |
# File 'lib/textus/boot.rb', line 111 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_summaries ⇒ Object
verb token => contract.summary, for every Dispatcher verb that carries a contract. The single source for a verb’s one-line summary (ADR 0039).
102 103 104 105 106 |
# File 'lib/textus/boot.rb', line 102 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
239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 |
# File 'lib/textus/boot.rb', line 239 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
256 257 258 |
# File 'lib/textus/boot.rb', line 256 def self.hooks_for_container(container) hooks_for_container_internal(rpc: container.rpc, events: container.events) end |
.hooks_for_container_internal(rpc:, events:) ⇒ Object
260 261 262 263 264 265 266 267 268 269 |
# File 'lib/textus/boot.rb', line 260 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.
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 184 185 |
# File 'lib/textus/boot.rb', line 149 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", ], }, "drain" => { "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", "drain (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
228 229 230 231 232 233 234 235 236 237 |
# File 'lib/textus/boot.rb', line 228 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 |