Project

textus

0.0
The project is in a healthy, maintained state
A coordination space for humans, AI, and automation. Durable, multi-writer project memory where each actor writes into its own lane, proposals cross a review queue, and every change is audited.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 13.0
~> 3.13

Runtime

>= 3.0
>= 5.0
>= 3.2
~> 2.6
 Project Readme

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
    subgraph writers["writers — who can write"]
        direction TB
        human(["human"])
        agent(["agent"])
        automation(["automation"])
    end

    human -->|author| knowledge["knowledge<br/>(canon)"]
    agent -->|keep| notebook["notebook<br/>(workspace)"]
    agent -->|propose| proposals["proposals<br/>(queue)"]
    automation -->|fetch| feeds["feeds<br/>(quarantine)"]
    automation -->|build| artifacts["artifacts<br/>(derived)"]

    proposals ==>|human accept| knowledge
    feeds -.->|projection source| artifacts
    knowledge -.->|projection source| artifacts

    classDef actor fill:#238636,stroke:#2ea043,color:#fff;
    classDef gate fill:#9e6a03,stroke:#bb8009,color:#fff;
    classDef anchor fill:#1f6feb,stroke:#388bfd,color:#fff;
    class human,agent,automation actor;
    class proposals gate;
    class knowledge anchor;
Loading

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). Colour legend: green = writers · amber = the review gate (proposals) · blue = the trust anchor (knowledge).

The point of those lanes is to build context you can trust. Place each lane on two axes — how durable it is, and how much you can rely on it without review — and the value shows up as a climb: the high-trust corner (durable and authoritative = knowledge) is the one place nothing is written directly. It's earned by crossing the accept gate.

                       LOW TRUST                     HIGH TRUST
                      (unreviewed)                (authoritative)
              ┌──────────────────────────┬───────────────────────────────┐
DURABLE       │  notebook                │  knowledge  ★ the goal        │
(kept)        │  agent's working truth   │  canon — a human authors      │
              │  durable, but low-trust  │  here · the context you ship  │
              ├──────────────────────────┼───────────────────────────────┤
TRANSIENT     │  feeds                   │  proposals  (queue)           │
(staging)     │  raw external input,     │  a candidate, in review       │
              │  unverified              │  ▲ climbs via human accept    │
              └──────────────────────────┴───────────────────────────────┘
                raw material ──── propose ────► a human accept lifts it to canon

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/how-to/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; a typed publish: block (to: for file fan-out, tree: for a whole-subtree mirror) byte-copies 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/how-to/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).

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.

Publishing is one typed publish: block (ADR 0052). publish: { to: [path, ...] } byte-copies a single derived file to one or more targets. publish: { tree: "dir" } on a nested entry mirrors its whole stored subtree to one target directory, preserving layout (path-driven — no keys or template variables). 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/how-to/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.