textus

Reference Ruby implementation of the textus/1 protocol — a storage convention and JSON wire protocol for agent-readable project memory: addressable dotted keys, schema-validated entries (markdown, JSON, YAML, or text per entry), role-gated writes, declarative compute, and copy-based publish targets.

See SPEC.md for the protocol. Implementation notes live in docs/.

Versioning

Two versions, deliberately independent:

  • Protocol wire string: textus/1. Stable; breaking changes require textus/2.
  • Gem version: semver, currently 0.2.0. Gem 0.x.y and 1.x both speak textus/1.

Envelope payloads carry the protocol field; the gem version is irrelevant to the wire format.

Install

gem install textus     # when published

Or from this repo:

bundle install
bundle exec exe/textus --help

Quick start

Bootstrap a fresh tree:

bundle exec exe/textus init

This scaffolds .textus/ with a starter manifest, the five zone directories, baseline schemas, and an empty audit log. The resulting layout:

.textus/
  manifest.yaml
  audit.log
  role
  schemas/
  templates/
  extensions/
  zones/
    canon/       # human-only
    working/     # human, ai, script
    intake/      # script (declared external inputs)
    pending/     # ai (proposals awaiting accept)
    derived/     # build only (computed outputs)

A minimal manifest.yaml:

version: textus/1

zones:
  - { name: canon,   writable_by: [human] }
  - { name: working, writable_by: [human, ai, script] }
  - { name: intake,  writable_by: [script] }
  - { name: pending, writable_by: [ai] }
  - { name: derived, writable_by: [build] }

entries:
  - key: canon.identity
    path: canon/identity.md
    zone: canon
    schema: identity

  - key: working.network.org
    path: working/network/org
    zone: working
    schema: person
    owner: textus:network
    nested: true

Manifest path: fields are relative to .textus/zones/ — implementations prepend zones/ when resolving. So working.network.org.jane lives at .textus/zones/working/network/org/jane.md.

Read and write:

textus get working.network.org.jane --format=json
textus list --zone=working --format=json
echo '{"frontmatter":{"name":"bob","relationship":"peer","org":"acme"},"body":"hi\n"}' \
  | textus put working.network.org.bob --as=human --stdin --format=json
textus stale --zone=derived --format=json

CLI verbs

All verbs accept --format=json and emit the envelope defined in SPEC §8. Write verbs require --as=<role> (subject to role-resolution order, §5.1).

Read verbs (no role required):

Verb Purpose
list [--prefix=K] [--zone=Z] [--stale] Enumerate keys, optionally filtered
where K Resolve a key to its filesystem path
get K Return the full envelope
schema K Return the schema bound to an entry
stale [--prefix=K] [--zone=Z] [--strict] List stale derived/intake entries
deps K / rdeps K Forward/reverse projection dependencies
published List publish_to: targets and their backing keys
validate-all Validate every entry against its schema (incl. maintained_by)
extensions list [--kind=K] Enumerate registered fetchers, reducers, and declared hooks

Write verbs (role-gated per zone):

Verb Role
put K --stdin --as=R [--fetcher=NAME] per zone
delete K --if-etag=E --as=R per zone
refresh K --as=script per zone (typically script)
build [--prefix=K] [--dry-run] build
accept K --as=human human only

Scaffolding (human-only):

Verb Purpose
init Scaffold a fresh .textus/ tree
schema-init NAME Write a stub schema
schema-diff NAME Compare on-disk schema against entries claiming it
schema-migrate NAME [--rename=OLD:NEW] Rewrite frontmatter keys across affected entries

Zones and roles

Zone writable_by Purpose
canon [human] Identity, voice, immutable principles
working [human, ai, script] Active project state — notes, decisions, network
intake [script] Declared external inputs (calendar, feeds, scraped pages)
pending [ai] AI proposals awaiting textus accept
derived [build] Computed outputs from textus build

The effective role for any CLI call is resolved in order: --as flag, then TEXTUS_ROLE env, then .textus/role, then default human. Mismatches return write_forbidden. Every write records the resolved role in .textus/audit.log.

Compute layer

Derived entries are not authored by hand. Each declares a projection: block (select prefixes, pluck fields, optional sort/limit/transform) and optionally a Mustache template under .textus/templates/. textus implements a deliberately restricted Mustache subset (variables, sections, inverted sections, comments — no partials, no lambdas, no HTML escaping). Results are bounded at 1000 rows; template recursion at depth 8.

Derived entries may declare format: to be markdown (default), json, yaml, or text. The in-store file is the consumer-shaped artifact — cat .textus/zones/derived/marketplace.json returns valid JSON without going through textus. publish_to: then performs a byte-for-byte file copy of that artifact to each destination, alongside a .textus-managed.json sentinel. See SPEC §5.2, §5.3, and §5.12.

Extension points

Three DSL verbs:

  • Textus.fetcher(:name) do |config:, store:| — pulls data into an intake entry. Returns one of { frontmatter:, body: }, { content: } (for format: json|yaml entries), or { body: } (raw bytes); the store normalizes all three. Configured via source.fetcher in the manifest. Five built-ins ship out of the box: json, csv, markdown-links, ical-events, rss.
  • Textus.reducer(:name) do |rows:, config:| — shapes rows in a derived projection. Pure function. Configured via projection.reducer.
  • Textus.hook(:event, :name) do |kwargs| — reacts to a lifecycle event. Five events: :put, :delete, :refresh, :build, :accept.

Extension files live in .textus/extensions/*.rb (one per registration, by convention). Each Store instance gets its own registry; no global state.

See SPEC.md §5.11 for the full contract.

Schema fields may also declare maintained_by: and a top-level evolution: block (added_in, deprecated_at, migrate_from). SPEC §5.8.

Examples

  • examples/claude-plugin/ — full tour: fetcher, reducer, lifecycle events, schema ownership, and a derived.claude.root entry published to CLAUDE.md.
  • examples/mcp-server/ — 50-line MCP server wrapping textus get/put as tools.

Tests

bundle exec rspec

Runs the full suite, including conformance fixtures A–I from SPEC §12.

Code quality

bundle exec rubocop      # lint
bundle exec rubocop -A   # lint + autocorrect

Git hooks via Lefthook:

brew bundle install      # installs lefthook (see Brewfile)
lefthook install         # writes .git/hooks/{pre-commit,pre-push}

Git hooks (defined in lefthook.yml):

  • pre-commit — runs rubocop on staged Ruby files.
  • pre-push — runs the full rspec suite and rubocop over the tree.

Bypass with LEFTHOOK=0 git commit ... when needed.

CI runs rspec (Ruby 3.3 / 3.4) and rubocop via GitHub Actions (.github/workflows/ci.yml).

License

MIT.