textus

CI Gem Version Gem Downloads Ruby License

A coordination space for humans, AI, and automation. Your agent forgets between sessions; your notes and CLAUDE.md get edited by whoever ran last; nobody can reconstruct who wrote what. textus is durable, multi-writer memory that stays current and survives the model, the session, and the vendor — you keep your space, agents keep theirs, automation keeps external data fresh, and every change crosses a review queue and an audit log.

textus is Latin for "the fabric a text is woven from" — same root as context, from con-texere, "to weave together."

The idea

Three actors write to your repo today:

  • Humans — you, your team. Authoritative on identity, decisions, voice.
  • Agents — Claude, Cursor, custom assistants. Smart, fast, forgetful, and not always right.
  • Automation — cron jobs, fetchers, CI. Bring outside data in and compile published artifacts.
flowchart LR
    human(["human"]) -->|author| knowledge["knowledge<br/>(canon)"]
    agent(["agent"]) -->|keep| notebook["notebook<br/>(workspace)"]
    agent -->|propose| proposals["proposals<br/>(queue)"]
    proposals -->|human accepts| knowledge
    automation(["automation"]) -->|fetch| feeds["feeds<br/>(quarantine)"]
    automation -->|build| artifacts["artifacts<br/>(derived)"]

Each actor writes only into its own lane; low-trust input climbs to authoritative lanes only by passing a guarded transition (an agent's proposal needs a human accept).

Without coordination, they overwrite each other and nothing remembers why. textus gives each actor a lane — called a zone in the manifest and CLI, the term used everywhere technical from here on — routes everything they can't write directly through a proposals queue, and writes every successful change to an append-only audit log. The lanes are enforced at the protocol level, not by convention.

knowledge/   author only        — who you are, what you decide, how you sound (knowledge.identity.* for identity facts)
notebook/    keep only          — agent's own durable lane (agents keep theirs; bytes climb to knowledge only via propose→accept)
feeds/       fetch only         — declared external inputs
proposals/   propose (agent + human) — proposals waiting on a human accept
artifacts/   build only         — computed, published artifacts

An agent that tries to write directly into knowledge/ gets write_forbidden. It writes to proposals/ (to change authoritative content) or its own notebook/ (for working memory). You accept the good proposals; textus promotes them, records the move, and audits both halves. Stable per-entry uid: means a reorganization doesn't break references. A monotonic audit cursor (textus pulse --since=N) means the next session — possibly a different agent, possibly a different model — picks up exactly where the last one left off.

That's the load-bearing claim: coordination is a protocol invariant, not a library convenience.

See it in four commands

gem install textus
textus init                          # creates .textus/ with zones + schemas
# agent proposes a change to proposals/
printf '%s' '{"_meta":{"name":"oncall","proposal":{"target_key":"knowledge.notes.oncall","action":"put"}},"body":"Patrick on call.\n"}' \
  | textus put proposals.notes.oncall --as=agent --stdin
# you accept it — textus promotes to knowledge/ and audits the move
textus accept proposals.notes.oncall --as=human

Try the gate the other way (textus put knowledge.notes.X --as=agent) and you get write_forbidden, with the role that would be allowed named in the error. That refusal is the whole point.

Try it

  • Worked end-to-end store — the role gate (propose → accept), build/publish (CLAUDE.md / AGENTS.md generated from knowledge entries), schemas, templates, and a hook: examples/project/
  • Wire textus into Claude Code via MCP — 4 steps, ~5 minutes: docs/agents-mcp.md

Protocol, not just a gem

This Ruby gem is the reference implementation of textus/3 — a wire format and storage convention any language can speak. The protocol owns the envelope shape, the role/zone gate, the audit log format, and the key grammar. The gem version (semver, see badge) and the protocol version (textus/3) move independently; envelopes carry the protocol field so consumers can pin to the contract, not the implementation.

A second implementation in another language would share the same .textus/ directory and the same audit log. That's deliberate.

Install

gem install textus

Or from this repo:

bundle install
bundle exec exe/textus --help

What textus init gives you

You get .textus/ with all five zone directories, baseline schemas, an empty audit log, and a starter manifest. Roles declare capabilities; each zone declares a kind:, and write authority is derived from the role's capabilities crossed with the zone's kind:

roles:
  - { name: human,      can: [author, propose] }
  - { name: agent,      can: [propose, keep] }
  - { name: automation, can: [fetch, build] }

zones:
  - { name: knowledge,  kind: canon }      # author — canonical truth
  - { name: notebook,   kind: workspace }  # keep — agent's own durable lane
  - { name: feeds,      kind: quarantine } # fetch — declared external inputs
  - { name: proposals,  kind: queue }      # propose — proposals awaiting accept
  - { name: artifacts,  kind: derived }    # build — computed outputs
.textus/
  manifest.yaml       # role capabilities + zone kinds + key-to-path mapping
  audit.log           # append-only NDJSON, every write
  schemas/            # YAML field shapes per entry family
  templates/          # mustache templates for derived entries
  hooks/              # one .rb per hook
  sentinels/          # publish bookkeeping
  zones/
    knowledge/        # author — identity (knowledge.identity.*), voice, decisions, notes
    notebook/         # keep — agent's own durable lane (agents keep theirs)
    feeds/            # fetch — declared external inputs (actions)
    proposals/        # propose (agent + human) — proposals awaiting accept
    artifacts/        # build — computed outputs

Manifest path: fields are relative to .textus/zones/. So knowledge.notes.org.jane lives at .textus/zones/knowledge/notes/org/jane.md.

Read and write:

textus get knowledge.notes.org.jane
textus list --zone=knowledge
printf '%s' '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
  | textus put knowledge.notes.bob --as=human --stdin
textus freshness --zone=artifacts    # per-entry fresh/stale/never_fetched/no_policy
textus rule list                     # show every rule block
textus audit --limit=20              # query the audit log

(All verbs return JSON envelopes by default; pass --output=json explicitly if you prefer.)

For a worked store — knowledge entries, a staged proposal, schemas, a template, and a build that publishes CLAUDE.md / AGENTS.md — see examples/project/.

What's shipped

  • Per-entry formats & publish. format: markdown|json|yaml|text per entry; publish_to:/publish_each: byte-copy derived files to their consumer paths. (SPEC §5.2–5.3)
  • Stable identity. Auto-minted uid: survives writes and textus key mv; reorganising never breaks references.
  • Capability × zone-kind gate. Writes carry --as=<role>; a role may write a zone iff it holds the capability the zone's kind: requires (canonauthor, workspacekeep, quarantinefetch, queuepropose, derivedbuild). The wrong role gets write_forbidden naming the capability needed and the roles that hold it. (SPEC §5)
  • Agent loop. textus boot orients a fresh session; textus pulse --since=N is the per-turn heartbeat (changed entries, stale keys, pending proposals). (docs/agents-mcp.md)
  • textus doctor. Health checks across schemas, hooks, keys, sentinels, and the audit log.

CLI and zones

All verbs accept --output=json and return the envelope defined in SPEC §8. Write verbs require --as=<role> (role resolution: --asTEXTUS_ROLE env → .textus/role file → default human). Default roles: human, agent, automation (rename or add your own in the manifest's roles: block).

  • Full verb table — read, write, health, scaffolding — is in SPEC §9.
  • Zone semantics and the capability × zone-kind mapping live in SPEC §5, with a tutorial expansion in docs/zones.md.

textus boot prints the same information for the current store: zones, entry families with schemas, registered hooks, write flows, and the verb catalog. Run it inside a store and you get the live picture; reach for the SPEC when you want the contract.

Compute and publish

Derived entries declare compute: { kind: projection, select: ..., pluck: ..., sort_by: ..., limit: ..., transform: name } and either a template under .textus/templates/ (markdown/text) or a templateless path that lets a transform hook shape the output directly (json/yaml). Projections cap at 1000 rows; the vendored Mustache subset caps at depth 8. No partials, no lambdas, no HTML escaping.

For externally-generated entries, declare compute: { kind: external, sources: [...] } — textus tracks the declared sources for staleness; the build automation produces the file.

publish_to: [path] byte-copies a single derived file to one target. publish_each: "template/{basename}.md" on a nested entry byte-copies every leaf to its templated target — substitutes {leaf}, {basename}, {key}, {ext}. Sentinels for every published file live under .textus/sentinels/. See SPEC §5.2, §5.3, §5.12.

Extension points

textus exposes a hook DSL. Drop .rb files into .textus/hooks/ (subdirectories are fine; files load alphabetically by full path). Events:

  • :resolve_intake — bring bytes in from elsewhere (returns {_meta:, body:})
  • :transform_rows — transform rows during projection (returns rows)
  • :validate — custom doctor check (returns issues)
  • :entry_put, :entry_deleted, :entry_fetched, :build_completed, :proposal_accepted, :file_published, :entry_renamed, :proposal_rejected, :store_loaded — react to lifecycle events
  • :fetch_started, :fetch_failed, :fetch_backgrounded — background-fetch lifecycle
# Inside .textus/hooks/local_file.rb
Textus.hook do |reg|
  reg.on(:resolve_intake, :local_file) do |config:, args:, **|
    path = config["path"] or raise "local-file requires intake.config.path"
    {
      _meta: { "last_fetched_at" => Time.now.utc.iso8601, "source_path" => path },
      body: File.read(File.expand_path(path)),
    }
  end
end
Textus.hook do |reg|
  reg.on(:transform_rows, :rank_by_recency) do |rows:, **|
    rows.sort_by { |r| r["updated_at"].to_s }.reverse
  end
end

To keep a batch of stale intake entries current in one shot:

textus fetch stale --prefix=feeds --zone=feeds --as=automation
# or just fetch everything stale in the feeds zone:
textus fetch stale --zone=feeds --as=automation

See SPEC.md §5.10 for the full hook contract.

Schemas (.textus/schemas/<name>.yaml) declare field shapes, per-field maintained_by: ownership, and an evolution: block (added_in, deprecated_at, migrate_from). Full contract in SPEC §5.8.

See docs/agents-mcp.md for the agent boot → pulse loop.

Examples

examples/project/ — textus as a project's own context store (a fictional Rails service, ledger). Human-authored knowledge/ (project facts, runbooks), a staged ADR in proposals/ showing the agent-propose / human-accept loop, schemas validating each family, a mustache template plus a :transform_rows hook, and a build that publishes the artifacts/orientation projection to CLAUDE.md and AGENTS.md. Includes a copy-paste adoption recipe for your own repo.

Tests

bundle exec rspec

Includes conformance fixtures A–I from SPEC §12.

Code quality

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

Lefthook hooks (brew bundle install then lefthook install) run rubocop on pre-commit and rspec + rubocop on pre-push. Bypass with LEFTHOOK=0 git commit ... when needed. CI runs rspec (Ruby 3.3 / 3.4) and rubocop via GitHub Actions.

License

MIT.