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 requiretextus/2. - Gem version: semver, currently
0.2.0. Gem0.x.yand1.xboth speaktextus/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: }(forformat: json|yamlentries), or{ body: }(raw bytes); the store normalizes all three. Configured viasource.fetcherin 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 viaprojection.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 aderived.claude.rootentry published toCLAUDE.md.examples/mcp-server/— 50-line MCP server wrappingtextus get/putas 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— runsrubocopon staged Ruby files.pre-push— runs the fullrspecsuite andrubocopover 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.