textus 0.35.1 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +135 -2
- data/README.md +34 -13
- data/SPEC.md +10 -4
- data/lib/textus/boot.rb +41 -21
- data/lib/textus/cli/verb/mcp_serve.rb +8 -3
- data/lib/textus/cli/verb/propose.rb +28 -0
- data/lib/textus/cli/verb/pulse.rb +12 -3
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb.rb +3 -2
- data/lib/textus/contract.rb +106 -0
- data/lib/textus/cursor_store.rb +24 -0
- data/lib/textus/dispatcher.rb +3 -1
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/fetch_locks.rb +2 -2
- data/lib/textus/doctor/check/illegal_keys.rb +10 -4
- data/lib/textus/domain/policy/evaluation.rb +3 -6
- data/lib/textus/init.rb +4 -0
- data/lib/textus/layout.rb +41 -0
- data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
- data/lib/textus/maintenance/migrate.rb +9 -0
- data/lib/textus/maintenance/rule_lint.rb +8 -0
- data/lib/textus/maintenance/zone_mv.rb +10 -0
- data/lib/textus/manifest/entry/base.rb +5 -0
- data/lib/textus/manifest/entry/ignore_matcher.rb +46 -0
- data/lib/textus/manifest/entry/nested.rb +9 -2
- data/lib/textus/manifest/entry/validators/ignore.rb +28 -0
- data/lib/textus/manifest/entry/validators.rb +1 -0
- data/lib/textus/manifest/resolver.rb +2 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/mcp/catalog.rb +72 -0
- data/lib/textus/mcp/server.rb +8 -5
- data/lib/textus/mcp/session.rb +3 -20
- data/lib/textus/mcp/tool_schemas.rb +6 -62
- data/lib/textus/mcp/tools.rb +4 -119
- data/lib/textus/ports/audit_log.rb +17 -15
- data/lib/textus/ports/build_lock.rb +1 -2
- data/lib/textus/ports/fetch/lock.rb +1 -1
- data/lib/textus/read/audit.rb +3 -3
- data/lib/textus/read/boot.rb +6 -0
- data/lib/textus/read/get.rb +8 -0
- data/lib/textus/read/list.rb +8 -0
- data/lib/textus/read/pulse.rb +7 -0
- data/lib/textus/read/rules.rb +24 -0
- data/lib/textus/read/schema_envelope.rb +7 -0
- data/lib/textus/role.rb +6 -2
- data/lib/textus/session.rb +24 -0
- data/lib/textus/store.rb +11 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +1 -1
- data/lib/textus/write/delete.rb +1 -1
- data/lib/textus/write/fetch_all.rb +8 -0
- data/lib/textus/write/fetch_worker.rb +9 -1
- data/lib/textus/write/mv.rb +1 -1
- data/lib/textus/write/propose.rb +46 -0
- data/lib/textus/write/put.rb +13 -1
- data/lib/textus/write/reject.rb +1 -1
- data/lib/textus.rb +4 -0
- metadata +15 -5
- data/docs/conventions.md +0 -148
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 15b2e8bcfb3e83425617ee187705b88ad99c1f96671a7ad04a8394073f98065d
|
|
4
|
+
data.tar.gz: e0711f287c8739fcc2e5f9507fd5637b9c63159b2d8e364f9d65f338138ff432
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74ec9edba22fdd7884c7bf1a16b9f73a636524c11f889824a516775857294ea674ebd6f6fe9b0a4d98028d30cf5673b51c376760968baa4d8413c83a33475484
|
|
7
|
+
data.tar.gz: c097ccd942039828754bd70ae2f457e172ca88166c318c72199138e126301aa4432cec318ff77c456a73de4176c0e6fe65160cca899111dcd27785adc9eff62c
|
data/CHANGELOG.md
CHANGED
|
@@ -9,7 +9,140 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
|
|
|
9
9
|
bump is a breaking change that requires a store migration; the gem version
|
|
10
10
|
tracks both additive improvements and breaking protocol bumps independently.
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## Unreleased
|
|
13
|
+
|
|
14
|
+
## 0.39.0 — 2026-06-01 — Native ignore patterns for entry enumeration ([ADR 0042](docs/architecture/decisions/0042-native-ignore-patterns-for-entry-enumeration.md))
|
|
15
|
+
|
|
16
|
+
No `textus/3` wire-format change. Manifest schema gains one optional, backward-compatible key (`ignore:`); existing manifests are unaffected.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **Per-entry `ignore:` globs on `nested` entries (ADR 0042).** A `nested`
|
|
21
|
+
entry may declare a list of gitignore-style globs (e.g.
|
|
22
|
+
`["**/node_modules/**", "**/dist/**"]`) to keep vendored or generated
|
|
23
|
+
subtrees out of the store. Patterns are honoured by **one shared filter
|
|
24
|
+
seam** consulted by both resolver enumeration (`list`, `build`) and
|
|
25
|
+
`textus doctor`, evaluated *above* key-legality: an ignored path is excluded,
|
|
26
|
+
never judged. This closes the prior divergence where a store could `list`
|
|
27
|
+
cleanly while `doctor` was red on the same vendored paths. Matching is
|
|
28
|
+
segment-wise globstar — `**` spans zero or more path segments; within a
|
|
29
|
+
segment `*` is anchored and `{a,b}` alternates (stdlib `File.fnmatch`,
|
|
30
|
+
no new dependency). Documented in
|
|
31
|
+
[`docs/reference/zones.md`](docs/reference/zones.md#nested-entries).
|
|
32
|
+
|
|
33
|
+
### Internal
|
|
34
|
+
|
|
35
|
+
- **Dogfood textus in its own repo ([ADR 0041](docs/architecture/decisions/0041-dogfood-textus-in-its-own-repo.md)).**
|
|
36
|
+
A self-development store and MCP wiring for textus's own repository. No change
|
|
37
|
+
to the published gem's behavior.
|
|
38
|
+
|
|
39
|
+
## 0.38.0 — 2026-05-31 — MCP serve acts as agent by default ([ADR 0040](docs/architecture/decisions/0040-mcp-connection-role-and-two-channels.md))
|
|
40
|
+
|
|
41
|
+
No `textus/3` wire-format change; no manifest-schema change.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- **The MCP connection acts as the `agent` role by default (ADR 0040).**
|
|
46
|
+
`textus mcp serve` now resolves its acting role through the standard chain
|
|
47
|
+
(`--as` → `TEXTUS_ROLE` → `.textus/role`) with an `agent` transport default,
|
|
48
|
+
instead of silently inheriting the global `human` default. The agent channel
|
|
49
|
+
proposes; human authority (accept/reject, direct writes) is exercised through
|
|
50
|
+
the human's own CLI.
|
|
51
|
+
|
|
52
|
+
### Breaking
|
|
53
|
+
|
|
54
|
+
- **MCP writes that relied on human authority now require `propose` + `accept`,
|
|
55
|
+
or an explicit `--as=human`.** A connection that previously `put` straight into
|
|
56
|
+
`working/`/`identity/` over MCP will get `write_forbidden`. Launch
|
|
57
|
+
`textus mcp serve --as=human` (or set `TEXTUS_ROLE`/`.textus/role`) to restore
|
|
58
|
+
the old behavior knowingly; the gate is then advisory.
|
|
59
|
+
|
|
60
|
+
## 0.37.0 — 2026-05-31 — MCP catalog derive-or-guard ([ADR 0039](docs/architecture/decisions/0039-mcp-catalog-derive-or-guard.md))
|
|
61
|
+
|
|
62
|
+
No `textus/3` wire-format change; no manifest-schema change.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- **MCP catalog is now derived from one per-verb contract (ADR 0039).** Each
|
|
67
|
+
use-case declares its interface once (`verb`/`summary`/`surfaces`/`arg`/`response`);
|
|
68
|
+
the MCP `tools/list` schemas and `tools/call` dispatch are generated from it.
|
|
69
|
+
The hand-written `Tools::REGISTRY` and `ToolSchemas` array are gone — a core
|
|
70
|
+
interface change can no longer leave MCP silently stale (it is derived, or a
|
|
71
|
+
guard spec fails the build).
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
|
|
75
|
+
- **`propose` and `rules` are first-class verbs** (Ruby/MCP; `propose` also CLI),
|
|
76
|
+
no longer MCP-only composed tools. MCP is now a pure projection of the core
|
|
77
|
+
verb set filtered by `surfaces(:mcp)`.
|
|
78
|
+
|
|
79
|
+
### Removed
|
|
80
|
+
|
|
81
|
+
- **Examples consolidated to a single reference.** Removed `examples/hello/` and
|
|
82
|
+
`examples/claude-plugin/`, keeping `examples/project/` as the one worked example —
|
|
83
|
+
the role gate (propose → accept), build/publish to `CLAUDE.md`/`AGENTS.md`, schemas,
|
|
84
|
+
a template, and a `:transform_rows` hook in one place. The `skill_fanout` recipe
|
|
85
|
+
sidecar, its spec, and the `docs/recipes/` page that existed only to document it are
|
|
86
|
+
removed alongside. All living docs and `boot`'s `docs.example` now point at
|
|
87
|
+
`examples/project/`.
|
|
88
|
+
|
|
89
|
+
### Breaking
|
|
90
|
+
|
|
91
|
+
- **MCP `schema` tool is keyed by entry `key`, not `family`.** It routes through
|
|
92
|
+
the `schema` (SchemaEnvelope) verb. Callers passing `{ "family": "..." }` must
|
|
93
|
+
pass `{ "key": "..." }` instead.
|
|
94
|
+
- **The dispatcher verb `schema_envelope` is renamed `schema`.** Ruby callers
|
|
95
|
+
using `store.as(role).schema_envelope(key)` must use `store.as(role).schema(key)`.
|
|
96
|
+
|
|
97
|
+
## 0.36.0 — 2026-05-31 — Transports as pure framings: one verb vocabulary, one session, lifted to core ([ADR 0036](docs/architecture/decisions/0036-transports-as-pure-framings.md))
|
|
98
|
+
|
|
99
|
+
No `textus/3` wire-format change; no manifest-schema change.
|
|
100
|
+
|
|
101
|
+
### Changed (BREAKING)
|
|
102
|
+
|
|
103
|
+
- **MCP tool names aligned with the CLI/Ruby verb vocabulary.** The five renamed tools adopt
|
|
104
|
+
the canonical core names: `tick`→`pulse`, `find`→`list`, `read`→`get`, `write`→`put`,
|
|
105
|
+
`fetch_stale`→`fetch_all`. The `textus/3` wire format is unchanged; agents that discover
|
|
106
|
+
tools via `tools/list` (the documented pattern) adapt automatically. Hardcoded tool names
|
|
107
|
+
in `.mcp.json` files, prompts, or scripts must be updated.
|
|
108
|
+
|
|
109
|
+
### Added
|
|
110
|
+
|
|
111
|
+
- **First-class CLI `propose` verb** — `textus propose KEY --as=ROLE [--stdin]` auto-prefixes
|
|
112
|
+
the manifest's `propose_zone`, matching what the MCP `propose` tool has always done. The
|
|
113
|
+
previous workaround (`textus put proposals.KEY --as=ROLE`) still works but the caller no
|
|
114
|
+
longer needs to know the queue zone name.
|
|
115
|
+
- **Stateful `textus pulse` (no `--since`)** — when `--since` is omitted, `pulse` reads and
|
|
116
|
+
updates a per-role cursor from `.textus/.state/cursor.<role>` (gitignored). Successive
|
|
117
|
+
invocations see only what changed since the last look, without hand-tracking a sequence
|
|
118
|
+
number. `--since=N` remains the explicit, stateless override.
|
|
119
|
+
- **`Textus::Session`** — the agent session (role + cursor + propose_zone + manifest_etag,
|
|
120
|
+
with cursor-advance, `ContractDrift` / `CursorExpired` detection) is now a core value
|
|
121
|
+
object, not MCP-internal state. `MCP::Session` is now an alias to it.
|
|
122
|
+
- **`Store#session(role:)`** — returns a `Textus::Session` for Ruby embedders; the
|
|
123
|
+
documented Ruby agent loop uses it instead of hand-tracking `since:`.
|
|
124
|
+
|
|
125
|
+
## 0.35.2 — 2026-05-31 — Evaluation field rename + Container doc fix (internal)
|
|
126
|
+
|
|
127
|
+
No `textus/3` wire-format change; no manifest-schema change; no library behavior
|
|
128
|
+
change. Internal refactor and documentation correction only.
|
|
129
|
+
|
|
130
|
+
### Changed
|
|
131
|
+
|
|
132
|
+
- `Domain::Policy::Evaluation` now names its manifest member `manifest` directly
|
|
133
|
+
instead of declaring it `snapshot` and exposing it through a `def manifest =
|
|
134
|
+
snapshot` alias. Every predicate already read `eval.manifest`; the field now
|
|
135
|
+
matches its only call name.
|
|
136
|
+
- Dropped the unused `def role = actor` alias on `Evaluation` (zero readers; the
|
|
137
|
+
real field `actor` is used everywhere).
|
|
138
|
+
|
|
139
|
+
### Fixed
|
|
140
|
+
|
|
141
|
+
- Architecture doc (`docs/architecture/README.md`) listed an `:authorizer` member
|
|
142
|
+
on `Container` that the code does not have. Removed it so the doc matches
|
|
143
|
+
`lib/textus/container.rb` (7 fields).
|
|
144
|
+
|
|
145
|
+
|
|
13
146
|
|
|
14
147
|
No `textus/3` wire-format change; no manifest-schema change; no library behavior change.
|
|
15
148
|
Test-suite maintenance only.
|
|
@@ -343,7 +476,7 @@ Protocol remains `textus/3`.
|
|
|
343
476
|
`refresh`, `refresh_stale`, `schema`, `rules`). Session state (cursor,
|
|
344
477
|
role, manifest_etag) held server-side. Manifest drift surfaces as
|
|
345
478
|
`ContractDrift` (-32001); cursor expiry as `CursorExpired` (-32002).
|
|
346
|
-
See [`docs/mcp.md`](docs/mcp.md) and [ADR 0015](docs/architecture/decisions/0015-agent-gate-mcp.md).
|
|
479
|
+
See [`docs/reference/mcp.md`](docs/reference/mcp.md) and [ADR 0015](docs/architecture/decisions/0015-agent-gate-mcp.md).
|
|
347
480
|
- `examples/claude-plugin/.mcp.json` and migrated skills/commands/agents —
|
|
348
481
|
zero `textus <verb>` shell strings remain in plugin markdown.
|
|
349
482
|
|
data/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
<p align="center">
|
|
9
9
|
<a href="https://github.com/patrick204nqh/textus/actions/workflows/ci.yml"><img src="https://github.com/patrick204nqh/textus/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
10
10
|
<a href="https://rubygems.org/gems/textus"><img src="https://img.shields.io/gem/v/textus.svg" alt="Gem Version"></a>
|
|
11
|
+
<a href="https://rubygems.org/gems/textus"><img src="https://img.shields.io/gem/dt/textus.svg" alt="Gem Downloads"></a>
|
|
11
12
|
<a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%E2%89%A53.3-CC342D.svg" alt="Ruby"></a>
|
|
12
13
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
|
|
13
14
|
</p>
|
|
@@ -29,13 +30,35 @@ flowchart LR
|
|
|
29
30
|
human(["human"]) -->|author| knowledge["knowledge<br/>(canon)"]
|
|
30
31
|
agent(["agent"]) -->|keep| notebook["notebook<br/>(workspace)"]
|
|
31
32
|
agent -->|propose| proposals["proposals<br/>(queue)"]
|
|
32
|
-
proposals
|
|
33
|
+
proposals ==>|human accept| knowledge
|
|
33
34
|
automation(["automation"]) -->|fetch| feeds["feeds<br/>(quarantine)"]
|
|
34
35
|
automation -->|build| artifacts["artifacts<br/>(derived)"]
|
|
36
|
+
feeds -.->|projection source| artifacts
|
|
37
|
+
knowledge -.->|projection source| artifacts
|
|
38
|
+
|
|
39
|
+
classDef anchor fill:#1f6feb,stroke:#1f6feb,color:#fff;
|
|
40
|
+
class knowledge anchor;
|
|
35
41
|
```
|
|
36
42
|
|
|
37
43
|
*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`).*
|
|
38
44
|
|
|
45
|
+
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.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
LOW TRUST HIGH TRUST
|
|
49
|
+
(unreviewed) (authoritative)
|
|
50
|
+
┌──────────────────────────┬───────────────────────────────┐
|
|
51
|
+
DURABLE │ notebook │ knowledge ★ the goal │
|
|
52
|
+
(kept) │ agent's working truth │ canon — a human authors │
|
|
53
|
+
│ durable, but low-trust │ here · the context you ship │
|
|
54
|
+
├──────────────────────────┼───────────────────────────────┤
|
|
55
|
+
TRANSIENT │ feeds │ proposals (queue) │
|
|
56
|
+
(staging) │ raw external input, │ a candidate, in review │
|
|
57
|
+
│ unverified │ ▲ climbs via human accept │
|
|
58
|
+
└──────────────────────────┴───────────────────────────────┘
|
|
59
|
+
raw material ──── propose ────► a human accept lifts it to canon
|
|
60
|
+
```
|
|
61
|
+
|
|
39
62
|
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.
|
|
40
63
|
|
|
41
64
|
```
|
|
@@ -66,10 +89,8 @@ Try the gate the other way (`textus put knowledge.notes.X --as=agent`) and you g
|
|
|
66
89
|
|
|
67
90
|
## Try it
|
|
68
91
|
|
|
69
|
-
- **
|
|
70
|
-
- **Wire textus into Claude Code via MCP** — 4 steps, ~5 minutes: [`docs/agents-mcp.md`](docs/agents-mcp.md)
|
|
71
|
-
- **Use textus as your own project's context store**: [`examples/project/`](examples/project/)
|
|
72
|
-
- **Use textus to author a Claude plugin** (textus is the source-of-truth, build publishes to `agents/`, `skills/`, `commands/`): [`examples/claude-plugin/`](examples/claude-plugin/)
|
|
92
|
+
- **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/`](examples/project/)
|
|
93
|
+
- **Wire textus into Claude Code via MCP** — 4 steps, ~5 minutes: [`docs/how-to/agents-mcp.md`](docs/how-to/agents-mcp.md)
|
|
73
94
|
|
|
74
95
|
## Protocol, not just a gem
|
|
75
96
|
|
|
@@ -144,22 +165,22 @@ textus audit --limit=20 # query the audit log
|
|
|
144
165
|
|
|
145
166
|
(All verbs return JSON envelopes by default; pass `--output=json` explicitly if you prefer.)
|
|
146
167
|
|
|
147
|
-
For
|
|
168
|
+
For a worked store — knowledge entries, a staged proposal, schemas, a template, and a build that publishes `CLAUDE.md` / `AGENTS.md` — see [`examples/project/`](examples/project/).
|
|
148
169
|
|
|
149
170
|
## What's shipped
|
|
150
171
|
|
|
151
172
|
- **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](SPEC.md))
|
|
152
173
|
- **Stable identity.** Auto-minted `uid:` survives writes and `textus key mv`; reorganising never breaks references.
|
|
153
174
|
- **Capability × zone-kind gate.** Writes carry `--as=<role>`; a role may write a zone iff it holds the capability the zone's `kind:` requires (`canon`→`author`, `workspace`→`keep`, `quarantine`→`fetch`, `queue`→`propose`, `derived`→`build`). The wrong role gets `write_forbidden` naming the capability needed and the roles that hold it. ([SPEC §5](SPEC.md))
|
|
154
|
-
- **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](docs/agents-mcp.md))
|
|
155
|
-
- **`textus doctor`.**
|
|
175
|
+
- **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](docs/how-to/agents-mcp.md))
|
|
176
|
+
- **`textus doctor`.** Health checks across schemas, hooks, keys, sentinels, and the audit log.
|
|
156
177
|
|
|
157
178
|
## CLI and zones
|
|
158
179
|
|
|
159
180
|
All verbs accept `--output=json` and return the envelope defined in [SPEC §8](SPEC.md). Write verbs require `--as=<role>` (role resolution: `--as` → `TEXTUS_ROLE` env → `.textus/role` file → default `human`). Default roles: `human`, `agent`, `automation` (rename or add your own in the manifest's `roles:` block).
|
|
160
181
|
|
|
161
182
|
- Full verb table — read, write, health, scaffolding — is in [SPEC §9](SPEC.md).
|
|
162
|
-
- Zone semantics and the capability × zone-kind mapping live in [SPEC §5](SPEC.md), with
|
|
183
|
+
- Zone semantics and the capability × zone-kind mapping live in [SPEC §5](SPEC.md), with the reference in [`docs/reference/zones.md`](docs/reference/zones.md).
|
|
163
184
|
|
|
164
185
|
`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.
|
|
165
186
|
|
|
@@ -214,11 +235,11 @@ See SPEC.md §5.10 for the full hook contract.
|
|
|
214
235
|
|
|
215
236
|
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.
|
|
216
237
|
|
|
217
|
-
See [`docs/agents-mcp.md`](docs/agents-mcp.md) for the agent boot → pulse loop.
|
|
238
|
+
See [`docs/how-to/agents-mcp.md`](docs/how-to/agents-mcp.md) for the agent boot → pulse loop.
|
|
218
239
|
|
|
219
240
|
## Examples
|
|
220
241
|
|
|
221
|
-
[`examples/
|
|
242
|
+
[`examples/project/`](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.
|
|
222
243
|
|
|
223
244
|
## Tests
|
|
224
245
|
|
|
@@ -226,7 +247,7 @@ See [`docs/agents-mcp.md`](docs/agents-mcp.md) for the agent boot → pulse loop
|
|
|
226
247
|
bundle exec rspec
|
|
227
248
|
```
|
|
228
249
|
|
|
229
|
-
|
|
250
|
+
Includes conformance fixtures A–I from SPEC §12.
|
|
230
251
|
|
|
231
252
|
## Code quality
|
|
232
253
|
|
|
@@ -239,4 +260,4 @@ Lefthook hooks (`brew bundle install` then `lefthook install`) run rubocop on `p
|
|
|
239
260
|
|
|
240
261
|
## License
|
|
241
262
|
|
|
242
|
-
MIT.
|
|
263
|
+
[MIT](LICENSE).
|
data/SPEC.md
CHANGED
|
@@ -632,7 +632,7 @@ Row transforms are RPC hooks on the `:transform_rows` event. See §5.10.
|
|
|
632
632
|
|
|
633
633
|
### 5.10 Hooks
|
|
634
634
|
|
|
635
|
-
This section is the normative event table. For the hook-author's guide (how to define and test hooks), see [`docs/
|
|
635
|
+
This section is the normative event table. For the hook-author's guide (how to define and test hooks), see [`docs/how-to/writing-hooks.md`](docs/how-to/writing-hooks.md).
|
|
636
636
|
|
|
637
637
|
textus has a single hook registration verb: `Textus.hook { |reg| reg.on(event, name, **opts) { ... } }`. The EVENTS table below defines every extension point. Files in `.textus/hooks/**/*.rb` are `load`ed at `Store#initialize` in alphabetical order by full path; the store-scoped loader drains the queued blocks and invokes each with its own registry.
|
|
638
638
|
|
|
@@ -896,11 +896,14 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
896
896
|
| `boot [--output=json]` | read | any |
|
|
897
897
|
| `pulse [--since=N]` | read | any |
|
|
898
898
|
| `put K --stdin --as=R [--fetch=NAME]` | write | per zone |
|
|
899
|
+
| `propose K --stdin --as=R` | write | `propose`-holder (auto-prefixes propose_zone) |
|
|
899
900
|
| `delete K --if-etag=E --as=R` | write | per zone |
|
|
900
901
|
| `fetch KEY --as=automation` | write | `fetch`-holder (typically `automation`) |
|
|
901
902
|
| `fetch stale [--prefix=K] [--zone=Z] [--as=automation]` | write | `fetch`-holder (typically `automation`) |
|
|
902
903
|
| `build [--prefix=K] [--dry-run]` | write | `build`-holder (typically `automation`) |
|
|
904
|
+
| `retain [--prefix=K] [--zone=Z] --as=ROLE` | write | per zone (role must write the matched zone) |
|
|
903
905
|
| `accept K --as=human` | write | `author`-holder (typically `human`) |
|
|
906
|
+
| `reject K --as=human` | write | `author`-holder (typically `human`) |
|
|
904
907
|
| `init` | write | `human` |
|
|
905
908
|
| `schema {show,init,diff,migrate}` | read/write | `human` for writes |
|
|
906
909
|
| `key mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
|
|
@@ -930,11 +933,14 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
930
933
|
"changed": [ { "seq": 1843, "key": "knowledge.notes.x", "verb": "put", "role": "human", "ts": "..." } ],
|
|
931
934
|
"stale": [ "artifacts.marketplace" ],
|
|
932
935
|
"pending_review": [ "proposals.proposal.123" ],
|
|
933
|
-
"doctor": { "ok": true, "warn": 0, "fail": 0 }
|
|
936
|
+
"doctor": { "ok": true, "warn": 0, "fail": 0 },
|
|
937
|
+
"manifest_etag": "sha256:1f3a…",
|
|
938
|
+
"next_due_at": "2026-06-01T09:00:00Z",
|
|
939
|
+
"hook_errors": [ { "seq": 1844, "event": "after_put", "hook": "notify", "key": "knowledge.notes.x", "error_class": "Timeout::Error", "error_message": "…", "at": "..." } ]
|
|
934
940
|
}
|
|
935
941
|
```
|
|
936
942
|
|
|
937
|
-
`cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `stale` is sourced from `freshness`. `pending_review` lists all keys in the queue zone. `doctor` is an `{ok, warn, fail}` count summary. When `--since` is below the oldest available seq (due to audit log rotation), pulse returns `CursorExpired`.
|
|
943
|
+
`cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `stale` is sourced from `freshness`. `pending_review` lists all keys in the queue zone. `doctor` is an `{ok, warn, fail}` count summary. `manifest_etag` is the `sha256:`-prefixed content hash of the manifest (ADR 0025), for cheap change-detection. `next_due_at` is the soonest upcoming freshness deadline across entries (ISO-8601, or `null` if none). `hook_errors` lists hook failures recorded since the cursor. When `--since` is below the oldest available seq (due to audit log rotation), pulse returns `CursorExpired`.
|
|
938
944
|
|
|
939
945
|
**`put` input** (read from stdin when `--stdin` is given):
|
|
940
946
|
|
|
@@ -997,7 +1003,7 @@ The reference Ruby gem follows semver independently and speaks `textus/3`.
|
|
|
997
1003
|
|
|
998
1004
|
Agents interact with a textus store through two verbs: `boot` (once per session, for orientation) and `pulse` (per turn, for deltas). The `boot` envelope's `agent_quickstart` block gives the agent its starting cursor (`latest_seq`), its writable zones, and its propose zone. The `pulse` verb returns a delta envelope keyed on that cursor. When audit log rotation expires a cursor, `CursorExpired` signals the agent to call `boot` again.
|
|
999
1005
|
|
|
1000
|
-
For the full boot → pulse loop with pseudocode and cursor-expiry handling, see [`docs/agents-mcp.md`](docs/agents-mcp.md).
|
|
1006
|
+
For the full boot → pulse loop with pseudocode and cursor-expiry handling, see [`docs/how-to/agents-mcp.md`](docs/how-to/agents-mcp.md).
|
|
1001
1007
|
|
|
1002
1008
|
## 12. Conformance fixtures
|
|
1003
1009
|
|
data/lib/textus/boot.rb
CHANGED
|
@@ -71,32 +71,52 @@ module Textus
|
|
|
71
71
|
},
|
|
72
72
|
}.freeze
|
|
73
73
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
CLI_VERBS
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
{ "name" => "
|
|
81
|
-
{ "name" => "
|
|
82
|
-
{ "name" => "
|
|
83
|
-
{ "name" => "
|
|
74
|
+
# Curated agent-facing verb catalog. For verbs that have a Dispatcher contract,
|
|
75
|
+
# the summary is derived from `contract.summary` at load time (ADR 0039). The
|
|
76
|
+
# editorial strings below are the fallback for CLI-only verbs without contracts.
|
|
77
|
+
# CLI_VERBS itself is assigned in textus.rb after Zeitwerk eager_load so that
|
|
78
|
+
# all contract-declaring files are loaded before derivation runs.
|
|
79
|
+
CURATED_CLI_VERBS = [
|
|
80
|
+
{ "name" => "boot" },
|
|
81
|
+
{ "name" => "list" },
|
|
82
|
+
{ "name" => "get" },
|
|
83
|
+
{ "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
|
|
84
|
+
{ "name" => "schema" },
|
|
85
|
+
{ "name" => "put" },
|
|
86
|
+
{ "name" => "propose" },
|
|
84
87
|
{ "name" => "accept", "summary" => "apply a queued proposal to its target zone; requires the author capability" },
|
|
85
88
|
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
|
|
86
89
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
87
90
|
{ "name" => "build", "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
|
|
88
|
-
{ "name" => "fetch"
|
|
91
|
+
{ "name" => "fetch" },
|
|
89
92
|
{ "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
|
|
90
|
-
{ "name" => "audit",
|
|
91
|
-
{ "name" => "blame",
|
|
92
|
-
{ "name" => "rule",
|
|
93
|
-
{ "name" => "doctor",
|
|
94
|
-
{ "name" => "hook",
|
|
95
|
-
|
|
96
|
-
{ "name" => "pulse",
|
|
97
|
-
"summary" => "delta since cursor — changed entries, stale, pending proposals, doctor summary" },
|
|
93
|
+
{ "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
|
|
94
|
+
{ "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
|
|
95
|
+
{ "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
|
|
96
|
+
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
97
|
+
{ "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
98
|
+
{ "name" => "pulse" },
|
|
98
99
|
].freeze
|
|
99
100
|
|
|
101
|
+
# Build the CLI verb catalog by deriving each summary from the corresponding
|
|
102
|
+
# Dispatcher contract when one exists, falling back to the editorial string for
|
|
103
|
+
# CLI-only verbs without a contract (e.g. accept, build, where). Called once
|
|
104
|
+
# from textus.rb after eager_load so all contract files are present.
|
|
105
|
+
def self.build_cli_verbs
|
|
106
|
+
by_contract = Dispatcher::VERBS.values
|
|
107
|
+
.select { |k| k.respond_to?(:contract?) && k.contract? }
|
|
108
|
+
.to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
|
|
109
|
+
|
|
110
|
+
CURATED_CLI_VERBS.map do |entry|
|
|
111
|
+
derived = by_contract[entry["name"]]
|
|
112
|
+
if derived
|
|
113
|
+
entry.merge("summary" => derived)
|
|
114
|
+
else
|
|
115
|
+
entry
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
100
120
|
def self.agent_quickstart(manifest, audit_log)
|
|
101
121
|
agent_role = manifest.policy.proposer_role
|
|
102
122
|
|
|
@@ -158,7 +178,7 @@ module Textus
|
|
|
158
178
|
"recipes" => recipes(manifest),
|
|
159
179
|
"role_resolution" => {
|
|
160
180
|
"summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
|
|
161
|
-
"default 'human'",
|
|
181
|
+
"then a transport default ('human' for CLI, 'agent' for MCP)",
|
|
162
182
|
"roles" => manifest.data.role_caps.keys,
|
|
163
183
|
"ref" => "SPEC.md §5",
|
|
164
184
|
},
|
|
@@ -177,7 +197,7 @@ module Textus
|
|
|
177
197
|
"cli_verbs" => CLI_VERBS.map(&:dup),
|
|
178
198
|
"agent_protocol" => agent_protocol(manifest),
|
|
179
199
|
"agent_quickstart" => agent_quickstart(manifest, container.audit_log),
|
|
180
|
-
"docs" => { "spec" => "SPEC.md", "example" => "examples/
|
|
200
|
+
"docs" => { "spec" => "SPEC.md", "example" => "examples/project/" },
|
|
181
201
|
}
|
|
182
202
|
end
|
|
183
203
|
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
|
-
# Launches the MCP stdio server in the current process. Blocks on
|
|
5
|
-
#
|
|
4
|
+
# Launches the MCP stdio server in the current process. Blocks on stdin;
|
|
5
|
+
# never returns until stdin closes. The connection acts as the `agent`
|
|
6
|
+
# role by default (ADR 0040): the agent channel proposes, it does not
|
|
7
|
+
# inherit the human's authority. Override per connection with --as, or
|
|
8
|
+
# TEXTUS_ROLE / .textus/role (same chain as every other verb).
|
|
6
9
|
class MCPServe < Verb
|
|
7
10
|
command_name "serve"
|
|
8
11
|
parent_group Group::MCP
|
|
12
|
+
option :as_flag, "--as=ROLE"
|
|
9
13
|
|
|
10
14
|
def call(store)
|
|
11
|
-
|
|
15
|
+
role = resolved_role(store, default: Textus::Role::AGENT)
|
|
16
|
+
Textus::MCP::Server.new(store: store, stdin: @stdin, stdout: @stdout, role: role).run
|
|
12
17
|
0
|
|
13
18
|
end
|
|
14
19
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Verb
|
|
4
|
+
# Queue a proposal. Mirrors the MCP `propose` tool: resolves the
|
|
5
|
+
# manifest's propose_zone and prefixes the key, so the author does not
|
|
6
|
+
# need to know the queue zone's name. ADR 0036.
|
|
7
|
+
class Propose < Verb
|
|
8
|
+
command_name "propose"
|
|
9
|
+
|
|
10
|
+
option :as_flag, "--as=ROLE"
|
|
11
|
+
option :use_stdin, "--stdin"
|
|
12
|
+
|
|
13
|
+
def call(store)
|
|
14
|
+
rel = positional.shift or raise UsageError.new("propose requires a key")
|
|
15
|
+
raise UsageError.new("propose requires --stdin") unless use_stdin
|
|
16
|
+
|
|
17
|
+
payload = JSON.parse(@stdin.read)
|
|
18
|
+
env = store.as(resolved_role(store)).propose(
|
|
19
|
+
rel,
|
|
20
|
+
meta: payload["_meta"] || {},
|
|
21
|
+
body: payload["body"] || "",
|
|
22
|
+
)
|
|
23
|
+
emit(env.to_h_for_wire)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -4,12 +4,21 @@ module Textus
|
|
|
4
4
|
class Pulse < Verb
|
|
5
5
|
command_name "pulse"
|
|
6
6
|
|
|
7
|
+
option :as_flag, "--as=ROLE"
|
|
7
8
|
option :since, "--since=N"
|
|
8
9
|
|
|
9
10
|
def call(store)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
role = resolved_role(store)
|
|
12
|
+
ops = store.as(role)
|
|
13
|
+
|
|
14
|
+
if since
|
|
15
|
+
emit(ops.pulse(since: since.to_i))
|
|
16
|
+
else
|
|
17
|
+
cursors = Textus::CursorStore.new(root: store.root, role: role)
|
|
18
|
+
result = ops.pulse(since: cursors.read)
|
|
19
|
+
cursors.write(result["cursor"])
|
|
20
|
+
emit(result)
|
|
21
|
+
end
|
|
13
22
|
end
|
|
14
23
|
end
|
|
15
24
|
end
|
data/lib/textus/cli/verb.rb
CHANGED
|
@@ -91,9 +91,10 @@ module Textus
|
|
|
91
91
|
|
|
92
92
|
# Resolves the active role for this invocation. Honors the verb's
|
|
93
93
|
# `--as` flag if declared, then TEXTUS_ROLE, then the project default.
|
|
94
|
-
|
|
94
|
+
# Pass `default:` to override the fallback (e.g. MCPServe uses AGENT).
|
|
95
|
+
def resolved_role(store, default: Role::DEFAULT)
|
|
95
96
|
flag = respond_to?(:as_flag) ? as_flag : nil
|
|
96
|
-
Role.resolve(flag: flag, env: ENV, root: store.root)
|
|
97
|
+
Role.resolve(flag: flag, env: ENV, root: store.root, default: default)
|
|
97
98
|
end
|
|
98
99
|
|
|
99
100
|
# Returns a Call value bound to the resolved role. Convenience for
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
# Declarative, co-located interface contract for a verb. One source of truth
|
|
3
|
+
# for the agent-facing summary, the argument schema, which transports expose
|
|
4
|
+
# the verb, and how the return value is shaped for the wire. CLI/Ruby/MCP and
|
|
5
|
+
# boot project from this; the MCP catalog is fully derived from it (ADR 0039).
|
|
6
|
+
module Contract
|
|
7
|
+
# One argument of a verb. `positional: true` means it is passed to the
|
|
8
|
+
# use-case as a positional (e.g. `get(key)`); otherwise as a keyword.
|
|
9
|
+
# `session_default` names a zero-arg method on `Textus::Session` (Symbol)
|
|
10
|
+
# that supplies the value when the wire arg is absent; `nil` means no default.
|
|
11
|
+
Arg = Data.define(:name, :type, :required, :positional, :session_default, :description)
|
|
12
|
+
|
|
13
|
+
JSON_TYPES = {
|
|
14
|
+
String => "string", Integer => "integer", Hash => "object",
|
|
15
|
+
Array => "array", :boolean => "boolean"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def self.json_type(type)
|
|
19
|
+
JSON_TYPES.fetch(type) { raise ArgumentError.new("no JSON type mapping for #{type.inspect}") }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Spec = Data.define(:verb, :summary, :args, :surfaces, :response) do
|
|
23
|
+
def mcp? = surfaces.include?(:mcp)
|
|
24
|
+
|
|
25
|
+
def required_args = args.select(&:required)
|
|
26
|
+
|
|
27
|
+
# JSON-Schema object for MCP tools/list inputSchema.
|
|
28
|
+
# Outer keys (:type, :properties, :required) are symbols; inner property
|
|
29
|
+
# keys are strings — matches the MCP/JSON wire shape expected by clients.
|
|
30
|
+
def input_schema
|
|
31
|
+
props = args.to_h do |a|
|
|
32
|
+
h = { "type" => Contract.json_type(a.type) }
|
|
33
|
+
h["description"] = a.description if a.description
|
|
34
|
+
[a.name.to_s, h]
|
|
35
|
+
end
|
|
36
|
+
{ type: "object", properties: props, required: required_args.map { |a| a.name.to_s } }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Mixed onto a use-case class via `extend`. Calls accumulate into ivars,
|
|
41
|
+
# frozen into a Spec on first read of `.contract`.
|
|
42
|
+
module DSL
|
|
43
|
+
def verb(name = nil)
|
|
44
|
+
if name
|
|
45
|
+
raise "contract already built; declare verb before reading .contract" if defined?(@__contract) && @__contract
|
|
46
|
+
|
|
47
|
+
@__verb = name
|
|
48
|
+
else
|
|
49
|
+
@__verb
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def summary(text = nil)
|
|
54
|
+
if text
|
|
55
|
+
raise "contract already built; declare summary before reading .contract" if defined?(@__contract) && @__contract
|
|
56
|
+
|
|
57
|
+
@__summary = text
|
|
58
|
+
else
|
|
59
|
+
@__summary
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def surfaces(*list)
|
|
64
|
+
if list.empty?
|
|
65
|
+
@__surfaces ||= []
|
|
66
|
+
else
|
|
67
|
+
raise "contract already built; declare surfaces before reading .contract" if defined?(@__contract) && @__contract
|
|
68
|
+
|
|
69
|
+
@__surfaces = list
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def arg(name, type, required: false, positional: false, session_default: nil, description: nil)
|
|
74
|
+
raise "contract already built; declare args before reading .contract" if defined?(@__contract) && @__contract
|
|
75
|
+
|
|
76
|
+
(@__args ||= []) << Arg.new(
|
|
77
|
+
name: name, type: type, required: required,
|
|
78
|
+
positional: positional, session_default: session_default, description: description
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def response(&blk)
|
|
83
|
+
@__response = blk if blk
|
|
84
|
+
@__response || ->(v) { v }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def contract?
|
|
88
|
+
!@__verb.nil?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# rubocop:disable Naming/MemoizedInstanceVariableName
|
|
92
|
+
# @__contract uses double-underscore to match the other accumulator ivars
|
|
93
|
+
# (@__verb, @__args, etc.) and avoid name collision with user-defined `@contract`.
|
|
94
|
+
def contract
|
|
95
|
+
@__contract ||= Spec.new(
|
|
96
|
+
verb: @__verb,
|
|
97
|
+
summary: @__summary,
|
|
98
|
+
args: (@__args || []).freeze,
|
|
99
|
+
surfaces: (@__surfaces || []).freeze,
|
|
100
|
+
response: response,
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|