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,
  fetch: lambda do |name, manifest|
    "fetch #{zone_label(manifest, :quarantine, "quarantine")} entries with " \
      "'textus fetch KEY --as=#{name}' (uses the entry's declared action)"
  end,
  build: lambda do |_name, manifest|
    derived = zone_label(manifest, :derived, "derived")
    "'textus build' computes #{derived} entries from projections; " \
      "#{derived} files are never hand-edited"
  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
CLI_VERBS =

The CLI verb catalog. Truth lives here; do not derive dynamically. Agents that read boot should see a stable shape regardless of how verb implementations evolve.

[
  { "name" => "boot",     "summary" => "this output — orientation for agents and tools" },
  { "name" => "list",     "summary" => "enumerate keys (optional --prefix)" },
  { "name" => "get",      "summary" => "read an entry; envelope with _meta, body, uid, etag" },
  { "name" => "where",    "summary" => "resolve a key to its zone and path without reading" },
  { "name" => "schema",   "summary" => "field shape for a key family" },
  { "name" => "put",      "summary" => "write an entry; --as=<role>, --stdin payload" },
  { "name" => "accept",   "summary" => "apply a queued proposal to its target zone; requires the author capability" },
  { "name" => "key",      "summary" => "key operations: 'key mv', 'key uid'" },
  { "name" => "delete",   "summary" => "delete an entry; --as=<role>" },
  { "name" => "build",    "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
  { "name" => "fetch", "summary" => "run an action for a quarantine entry" },
  { "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
  { "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
  { "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
  { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
  { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
  { "name" => "hook",
    "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
  { "name" => "pulse",
    "summary" => "delta since cursor — changed entries, stale, pending proposals, doctor summary" },
].freeze

Class Method Summary collapse

Class Method Details

.agent_protocol(manifest) ⇒ Object



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

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, " \
                   "default 'human'",
      "roles" => manifest.data.role_caps.keys,
      "ref" => "SPEC.md §5",
    },
  )
end

.agent_quickstart(manifest, audit_log) ⇒ Object



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

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)

  {
    "read_verbs" => %w[boot get list audit pulse freshness doctor],
    "write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
    "writable_zones" => writable_zones,
    "propose_zone" => propose_zone,
    "latest_seq" => audit_log.latest_seq,
  }
end

.build(container:) ⇒ Object



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

def self.build(container:)
  manifest = container.manifest
  {
    "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),
    "docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
  }
end

.entries_for(manifest) ⇒ Object



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/textus/boot.rb', line 195

def self.entries_for(manifest)
  manifest.data.entries.map do |e|
    derived = manifest.policy.derived_zone?(e.zone)
    {
      "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.is_a?(Textus::Manifest::Entry::Intake),
      "publish_to" => Array(e.publish_to),
      "publish_each" => e.publish_each,
    }
  end
end

.hooks_for_container(container) ⇒ Object



213
214
215
# File 'lib/textus/boot.rb', line 213

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

.hooks_for_container_internal(rpc:, events:) ⇒ Object



217
218
219
220
221
222
223
224
225
226
# File 'lib/textus/boot.rb', line 217

def self.hooks_for_container_internal(rpc:, events:)
  sections = {}
  Hooks::RpcRegistry::EVENTS.each_key do |event|
    sections[event.to_s] = rpc.names(event).map(&:to_s).sort
  end
  Hooks::EventBus::EVENTS.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



118
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
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/textus/boot.rb', line 118

def self.recipes(manifest)
  queue = manifest.policy.queue_zone
  feeds = zone_label(manifest, :quarantine, "the quarantine zone")
  {
    "read" => {
      "purpose" => "find and read an entry",
      "steps" => [
        "textus list --zone=ZONE --prefix=PREFIX  # discover keys",
        "textus get KEY                            # returns envelope JSON",
      ],
    },
    "write" => {
      "purpose" => "create or update an entry",
      "steps" => [
        "textus schema get FAMILY                  # learn the _meta field shape",
        "build an envelope JSON: {_meta: {...}, body: \"...\"}",
        "echo ENVELOPE | textus put KEY --as=ROLE --stdin",
      ],
    },
    "propose" => {
      "purpose" => "agent suggests a change for human review",
      "agent_steps" => [
        "echo ENVELOPE | textus put #{queue}.KEY --as=agent --stdin",
      ],
      "human_steps" => [
        "textus accept #{queue}.KEY --as=human       # promotes the proposal to its target zone",
      ],
    },
    "fetch" => {
      "purpose" => "rebuild stale quarantine-zone caches from their declared actions",
      "steps" => [
        "textus freshness --zone=#{feeds}            # report fresh/stale per entry",
        "textus fetch stale --zone=#{feeds} --as=automation",
      ],
    },
  }
end

.write_flows_for(manifest) ⇒ Object



40
41
42
43
44
45
46
47
48
# File 'lib/textus/boot.rb', line 40

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



53
54
55
56
# File 'lib/textus/boot.rb', line 53

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

.zones_for(manifest) ⇒ Object



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

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