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 =
Textus::Boot.build_cli_verbs.freeze

Class Method Summary collapse

Class Method Details

.agent_protocol(manifest) ⇒ Object



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/textus/boot.rb', line 123

def self.agent_protocol(manifest)
  queue = manifest.policy.queue_lane
  feeds = lane_label(manifest, :machine, "the machine lane")
  AGENT_PROTOCOL_TEMPLATE.merge(
    "recipes" => {
      "read" => { "purpose" => "find and read an entry",
                  "steps" => ["list (lane:, prefix:) — discover keys", "get KEY — returns the entry envelope"] },
      "write" => { "purpose" => "create or update an entry",
                   "steps" => ["schema KEY — learn field shape", "put KEY — persist it (role-gated)"] },
      "propose" => { "purpose" => "agent suggests a change for human review",
                     "agent_steps" => ["propose KEY — writes to #{queue} lane"],
                     "human_steps" => ["accept #{queue}.KEY — promotes to target lane"] },
      "drain" => { "purpose" => "keep machine lanes fresh",
                   "steps" => ["pulse — stale list names overdue entries",
                               "drain (lane: #{feeds}) — re-pull stale entries"] },
    },
    "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

.build(container:) ⇒ Object



75
76
77
78
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 75

def self.build(container:)
  etag       = Textus::Value::Etag.for_contract(container.root)
  latest_seq = container.audit_log.latest_seq
  artifact   = read_artifact_content(container, "artifacts.boot")
  context    = read_boot_context(container)

  # Prefer pre-computed artifact (drain computes, boot reads).
  # Fall back to inline manifest projection for stores that have not yet
  # run drain (test fixtures, fresh inits).
  stable = artifact || inline_boot_content(container.manifest, latest_seq)

  if stable["agent_quickstart"]
    stable = stable.merge(
      "agent_quickstart" => stable["agent_quickstart"].merge("latest_seq" => latest_seq),
    )
  end

  payload = {
    "protocol" => PROTOCOL_ID,
    "store_root" => container.root,
    "contract_etag" => etag,
  }.merge(stable)
  payload["context"] = context if context
  payload
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.



67
68
69
70
71
72
73
# File 'lib/textus/boot.rb', line 67

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



60
61
62
# File 'lib/textus/boot.rb', line 60

def self.contract_summaries
  Textus::VerbRegistry.registered.to_h { |s| [s.verb.to_s, s.summary] }
end

.inline_boot_content(manifest, _latest_seq) ⇒ Object



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/textus/boot.rb', line 101

def self.inline_boot_content(manifest, _latest_seq)
  agent_role     = manifest.policy.proposer_role
  writable_lanes = manifest.data.declared_lane_kinds.keys.each_with_object([]) do |ln, acc|
    next unless agent_role

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

  {
    "lanes" => lanes_for(manifest),
    "agent_quickstart" => {
      "read_verbs" => Textus::Surface::MCP::Catalog.read_verbs,
      "write_verbs" => agent_role ? Textus::Surface::MCP::Catalog.write_verbs : [],
      "writable_lanes" => writable_lanes,
      "propose_lane" => manifest.policy.propose_lane_for(agent_role),
    },
    "agent_protocol" => agent_protocol(manifest),
  }
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



148
149
150
151
152
153
154
155
156
157
158
# File 'lib/textus/boot.rb', line 148

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



160
161
162
163
164
165
166
167
168
# File 'lib/textus/boot.rb', line 160

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

  env = Textus::Store::Entry::Reader.from(container: container).read(key)
  env&.content
rescue Textus::Error
  nil
end

.read_boot_context(container) ⇒ Object



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

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

  env = Textus::Store::Entry::Reader.from(container: container).read("knowledge.boot")
  body = env&.body&.strip
  body.nil? || body.empty? ? nil : body
rescue Textus::Error
  nil
end