Module: Textus::Boot

Defined in:
lib/textus/boot.rb

Overview

Read-only “what’s in this store and how do I use it” envelope. Boot is side-effect-free. Reads from pre-computed artifacts and the store catalog rather than computing inline.

Constant Summary collapse

PROTOCOL_ID =
PROTOCOL
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 lane 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" => "ingest" },
  { "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" => "jobs" },
  { "name" => "pulse" },
].freeze
CLI_VERBS =

Derive CLI_VERBS after VERBS is available.

Textus::Boot.build_cli_verbs.freeze

Class Method Summary collapse

Class Method Details

.agent_protocol(manifest) ⇒ Object



139
140
141
142
143
144
145
146
147
148
149
# File 'lib/textus/boot.rb', line 139

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



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/textus/boot.rb', line 79

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

  writable_lanes = manifest.data.declared_lane_kinds.keys.each_with_object([]) do |lane_name, acc|
    next unless agent_role

    verb = manifest.policy.verb_for_lane(lane_name)
    writers = manifest.policy.roles_with_capability(verb)
    acc << lane_name if writers.include?(agent_role)
  end

  propose_lane = manifest.policy.propose_lane_for(agent_role)

  {
    "read_verbs" => Textus::Surfaces::MCP::Catalog.read_verbs,
    "write_verbs" => agent_role ? Textus::Surfaces::MCP::Catalog.write_verbs : [],
    "writable_lanes" => writable_lanes,
    "propose_lane" => propose_lane,
    "latest_seq" => audit_log.latest_seq,
  }
end

.build(container:) ⇒ Object



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/textus/boot.rb', line 151

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

  {
    "protocol" => PROTOCOL_ID,
    "store_root" => container.root,
    "contract_etag" => etag,
    "lanes" => lanes_for(manifest),
    "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
    "orientation" => read_artifact_content(container, "artifacts.orientation"),
    "context" => read_boot_context(container),
    "index_key" => "artifacts.index",
    "agent_protocol" => agent_protocol(manifest),
  }.compact
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.



71
72
73
74
75
76
77
# File 'lib/textus/boot.rb', line 71

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).



62
63
64
65
66
# File 'lib/textus/boot.rb', line 62

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

.lane_label(manifest, kind, fallback) ⇒ Object

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



11
12
13
14
# File 'lib/textus/boot.rb', line 11

def self.lane_label(manifest, kind, fallback)
  lanes = manifest.policy.lanes_of_kind(kind)
  lanes.empty? ? fallback : lanes.join(", ")
end

.lanes_for(manifest) ⇒ Object



191
192
193
194
195
196
197
198
199
200
201
# File 'lib/textus/boot.rb', line 191

def self.lanes_for(manifest)
  manifest.data.declared_lane_kinds.keys.map do |name|
    verb = manifest.policy.verb_for_lane(name)
    row = { "name" => name, "writers" => manifest.policy.roles_with_capability(verb) }
    kind = manifest.policy.declared_kind(name)
    row["kind"] = kind.to_s if kind
    purpose = manifest.data.lane_descs[name]
    row["purpose"] = purpose if purpose && !purpose.empty?
    row
  end
end

.read_artifact_content(container, key) ⇒ Object



168
169
170
171
172
173
174
175
176
177
# File 'lib/textus/boot.rb', line 168

def self.read_artifact_content(container, key)
  res = container.manifest.resolver.resolve(key)
  return nil unless res.path && File.exist?(res.path)

  call = Textus::Call.build(role: Textus::Role::DEFAULT)
  env  = Textus::Action::Get.new(key: key).call(container: container, call: call)
  env&.content
rescue Textus::Error
  nil
end

.read_boot_context(container) ⇒ Object



179
180
181
182
183
184
185
186
187
188
189
# File 'lib/textus/boot.rb', line 179

def self.read_boot_context(container)
  res = container.manifest.resolver.resolve("knowledge.boot")
  return nil unless res.path && File.exist?(res.path)

  call = Textus::Call.build(role: Textus::Role::DEFAULT)
  env  = Textus::Action::Get.new(key: "knowledge.boot").call(container: container, call: call)
  body = env&.body&.strip
  body.nil? || body.empty? ? nil : body
rescue Textus::Error
  nil
end

.recipes(manifest) ⇒ Object



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/textus/boot.rb', line 101

def self.recipes(manifest)
  queue = manifest.policy.queue_lane
  feeds = lane_label(manifest, :machine, "the machine lane")
  {
    "read" => {
      "purpose" => "find and read an entry",
      "steps" => [
        "list (lane:, 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} lane for review",
      ],
      "human_steps" => [
        "accept #{queue}.KEY — promotes the proposal into its target lane",
      ],
    },
    "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 (lane: #{feeds}) — re-pull the stale entries",
      ],
    },
  }
end