textus 0.35.1 → 0.38.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 +109 -1
- data/README.md +7 -8
- data/SPEC.md +8 -2
- data/docs/conventions.md +1 -1
- 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/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/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 +13 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 020c9cf77e098bd7099a5cd0871801b40d7be8266b2942c8ddb6cfed60c85af0
|
|
4
|
+
data.tar.gz: d4bfacdf7f6049df88e37b53e24ff4927f4bfd20f3d0ff55d5ed4d54ecbfb1fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aefe80bba5a38c4db29fe663cf3f41c77229075cc5b02b95b9fac0c8df62b81aed936bef95dd610becb6c700238936ddd8dd528762dae576747552ab4e9acbac
|
|
7
|
+
data.tar.gz: 6d342cad8cb4dd0d3f320990c45ca0c4a996e5499dafd9c90f72b53731d53294f57b8dea2b4c625d96e43245da2138f3df8971d9ba416940cdc59a390754f577
|
data/CHANGELOG.md
CHANGED
|
@@ -9,7 +9,115 @@ 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.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))
|
|
15
|
+
|
|
16
|
+
No `textus/3` wire-format change; no manifest-schema change.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **The MCP connection acts as the `agent` role by default (ADR 0040).**
|
|
21
|
+
`textus mcp serve` now resolves its acting role through the standard chain
|
|
22
|
+
(`--as` → `TEXTUS_ROLE` → `.textus/role`) with an `agent` transport default,
|
|
23
|
+
instead of silently inheriting the global `human` default. The agent channel
|
|
24
|
+
proposes; human authority (accept/reject, direct writes) is exercised through
|
|
25
|
+
the human's own CLI.
|
|
26
|
+
|
|
27
|
+
### Breaking
|
|
28
|
+
|
|
29
|
+
- **MCP writes that relied on human authority now require `propose` + `accept`,
|
|
30
|
+
or an explicit `--as=human`.** A connection that previously `put` straight into
|
|
31
|
+
`working/`/`identity/` over MCP will get `write_forbidden`. Launch
|
|
32
|
+
`textus mcp serve --as=human` (or set `TEXTUS_ROLE`/`.textus/role`) to restore
|
|
33
|
+
the old behavior knowingly; the gate is then advisory.
|
|
34
|
+
|
|
35
|
+
## 0.37.0 — 2026-05-31 — MCP catalog derive-or-guard ([ADR 0039](docs/architecture/decisions/0039-mcp-catalog-derive-or-guard.md))
|
|
36
|
+
|
|
37
|
+
No `textus/3` wire-format change; no manifest-schema change.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **MCP catalog is now derived from one per-verb contract (ADR 0039).** Each
|
|
42
|
+
use-case declares its interface once (`verb`/`summary`/`surfaces`/`arg`/`response`);
|
|
43
|
+
the MCP `tools/list` schemas and `tools/call` dispatch are generated from it.
|
|
44
|
+
The hand-written `Tools::REGISTRY` and `ToolSchemas` array are gone — a core
|
|
45
|
+
interface change can no longer leave MCP silently stale (it is derived, or a
|
|
46
|
+
guard spec fails the build).
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **`propose` and `rules` are first-class verbs** (Ruby/MCP; `propose` also CLI),
|
|
51
|
+
no longer MCP-only composed tools. MCP is now a pure projection of the core
|
|
52
|
+
verb set filtered by `surfaces(:mcp)`.
|
|
53
|
+
|
|
54
|
+
### Removed
|
|
55
|
+
|
|
56
|
+
- **Examples consolidated to a single reference.** Removed `examples/hello/` and
|
|
57
|
+
`examples/claude-plugin/`, keeping `examples/project/` as the one worked example —
|
|
58
|
+
the role gate (propose → accept), build/publish to `CLAUDE.md`/`AGENTS.md`, schemas,
|
|
59
|
+
a template, and a `:transform_rows` hook in one place. The `skill_fanout` recipe
|
|
60
|
+
sidecar, its spec, and the `docs/recipes/` page that existed only to document it are
|
|
61
|
+
removed alongside. All living docs and `boot`'s `docs.example` now point at
|
|
62
|
+
`examples/project/`.
|
|
63
|
+
|
|
64
|
+
### Breaking
|
|
65
|
+
|
|
66
|
+
- **MCP `schema` tool is keyed by entry `key`, not `family`.** It routes through
|
|
67
|
+
the `schema` (SchemaEnvelope) verb. Callers passing `{ "family": "..." }` must
|
|
68
|
+
pass `{ "key": "..." }` instead.
|
|
69
|
+
- **The dispatcher verb `schema_envelope` is renamed `schema`.** Ruby callers
|
|
70
|
+
using `store.as(role).schema_envelope(key)` must use `store.as(role).schema(key)`.
|
|
71
|
+
|
|
72
|
+
## 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))
|
|
73
|
+
|
|
74
|
+
No `textus/3` wire-format change; no manifest-schema change.
|
|
75
|
+
|
|
76
|
+
### Changed (BREAKING)
|
|
77
|
+
|
|
78
|
+
- **MCP tool names aligned with the CLI/Ruby verb vocabulary.** The five renamed tools adopt
|
|
79
|
+
the canonical core names: `tick`→`pulse`, `find`→`list`, `read`→`get`, `write`→`put`,
|
|
80
|
+
`fetch_stale`→`fetch_all`. The `textus/3` wire format is unchanged; agents that discover
|
|
81
|
+
tools via `tools/list` (the documented pattern) adapt automatically. Hardcoded tool names
|
|
82
|
+
in `.mcp.json` files, prompts, or scripts must be updated.
|
|
83
|
+
|
|
84
|
+
### Added
|
|
85
|
+
|
|
86
|
+
- **First-class CLI `propose` verb** — `textus propose KEY --as=ROLE [--stdin]` auto-prefixes
|
|
87
|
+
the manifest's `propose_zone`, matching what the MCP `propose` tool has always done. The
|
|
88
|
+
previous workaround (`textus put proposals.KEY --as=ROLE`) still works but the caller no
|
|
89
|
+
longer needs to know the queue zone name.
|
|
90
|
+
- **Stateful `textus pulse` (no `--since`)** — when `--since` is omitted, `pulse` reads and
|
|
91
|
+
updates a per-role cursor from `.textus/.state/cursor.<role>` (gitignored). Successive
|
|
92
|
+
invocations see only what changed since the last look, without hand-tracking a sequence
|
|
93
|
+
number. `--since=N` remains the explicit, stateless override.
|
|
94
|
+
- **`Textus::Session`** — the agent session (role + cursor + propose_zone + manifest_etag,
|
|
95
|
+
with cursor-advance, `ContractDrift` / `CursorExpired` detection) is now a core value
|
|
96
|
+
object, not MCP-internal state. `MCP::Session` is now an alias to it.
|
|
97
|
+
- **`Store#session(role:)`** — returns a `Textus::Session` for Ruby embedders; the
|
|
98
|
+
documented Ruby agent loop uses it instead of hand-tracking `since:`.
|
|
99
|
+
|
|
100
|
+
## 0.35.2 — 2026-05-31 — Evaluation field rename + Container doc fix (internal)
|
|
101
|
+
|
|
102
|
+
No `textus/3` wire-format change; no manifest-schema change; no library behavior
|
|
103
|
+
change. Internal refactor and documentation correction only.
|
|
104
|
+
|
|
105
|
+
### Changed
|
|
106
|
+
|
|
107
|
+
- `Domain::Policy::Evaluation` now names its manifest member `manifest` directly
|
|
108
|
+
instead of declaring it `snapshot` and exposing it through a `def manifest =
|
|
109
|
+
snapshot` alias. Every predicate already read `eval.manifest`; the field now
|
|
110
|
+
matches its only call name.
|
|
111
|
+
- Dropped the unused `def role = actor` alias on `Evaluation` (zero readers; the
|
|
112
|
+
real field `actor` is used everywhere).
|
|
113
|
+
|
|
114
|
+
### Fixed
|
|
115
|
+
|
|
116
|
+
- Architecture doc (`docs/architecture/README.md`) listed an `:authorizer` member
|
|
117
|
+
on `Container` that the code does not have. Removed it so the doc matches
|
|
118
|
+
`lib/textus/container.rb` (7 fields).
|
|
119
|
+
|
|
120
|
+
|
|
13
121
|
|
|
14
122
|
No `textus/3` wire-format change; no manifest-schema change; no library behavior change.
|
|
15
123
|
Test-suite maintenance only.
|
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>
|
|
@@ -66,10 +67,8 @@ Try the gate the other way (`textus put knowledge.notes.X --as=agent`) and you g
|
|
|
66
67
|
|
|
67
68
|
## Try it
|
|
68
69
|
|
|
69
|
-
- **
|
|
70
|
+
- **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/)
|
|
70
71
|
- **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/)
|
|
73
72
|
|
|
74
73
|
## Protocol, not just a gem
|
|
75
74
|
|
|
@@ -144,7 +143,7 @@ textus audit --limit=20 # query the audit log
|
|
|
144
143
|
|
|
145
144
|
(All verbs return JSON envelopes by default; pass `--output=json` explicitly if you prefer.)
|
|
146
145
|
|
|
147
|
-
For
|
|
146
|
+
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
147
|
|
|
149
148
|
## What's shipped
|
|
150
149
|
|
|
@@ -152,7 +151,7 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
|
|
|
152
151
|
- **Stable identity.** Auto-minted `uid:` survives writes and `textus key mv`; reorganising never breaks references.
|
|
153
152
|
- **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
153
|
- **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`.**
|
|
154
|
+
- **`textus doctor`.** Health checks across schemas, hooks, keys, sentinels, and the audit log.
|
|
156
155
|
|
|
157
156
|
## CLI and zones
|
|
158
157
|
|
|
@@ -218,7 +217,7 @@ See [`docs/agents-mcp.md`](docs/agents-mcp.md) for the agent boot → pulse loop
|
|
|
218
217
|
|
|
219
218
|
## Examples
|
|
220
219
|
|
|
221
|
-
[`examples/
|
|
220
|
+
[`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
221
|
|
|
223
222
|
## Tests
|
|
224
223
|
|
|
@@ -226,7 +225,7 @@ See [`docs/agents-mcp.md`](docs/agents-mcp.md) for the agent boot → pulse loop
|
|
|
226
225
|
bundle exec rspec
|
|
227
226
|
```
|
|
228
227
|
|
|
229
|
-
|
|
228
|
+
Includes conformance fixtures A–I from SPEC §12.
|
|
230
229
|
|
|
231
230
|
## Code quality
|
|
232
231
|
|
|
@@ -239,4 +238,4 @@ Lefthook hooks (`brew bundle install` then `lefthook install`) run rubocop on `p
|
|
|
239
238
|
|
|
240
239
|
## License
|
|
241
240
|
|
|
242
|
-
MIT.
|
|
241
|
+
[MIT](LICENSE).
|
data/SPEC.md
CHANGED
|
@@ -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
|
|
data/docs/conventions.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Conventions
|
|
2
2
|
|
|
3
3
|
> **Reference** · for integrators · **read when** you're shaping a `.textus/` tree and want the idiomatic choices
|
|
4
|
-
> **SSoT for** idiomatic key naming, schema design, and automation integration · **reviewed** 2026-05 (v0.
|
|
4
|
+
> **SSoT for** idiomatic key naming, schema design, and automation integration · **reviewed** 2026-05 (v0.35)
|
|
5
5
|
|
|
6
6
|
Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and integrating with build automation. The spec ([`../SPEC.md`](../SPEC.md)) defines what's enforceable; this document captures what's *idiomatic*.
|
|
7
7
|
|
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
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
# Per-role cursor cache under <root>/.run/state/cursor.<role>. A convenience so
|
|
5
|
+
# `textus pulse` (no --since) means "since I last looked". Gitignored;
|
|
6
|
+
# losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
|
|
7
|
+
class CursorStore
|
|
8
|
+
def initialize(root:, role:)
|
|
9
|
+
@path = Textus::Layout.cursor(root, role)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def read
|
|
13
|
+
Integer(File.read(@path).strip)
|
|
14
|
+
rescue Errno::ENOENT, ArgumentError
|
|
15
|
+
0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def write(seq)
|
|
19
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
20
|
+
File.write(@path, seq.to_s)
|
|
21
|
+
seq
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/textus/dispatcher.rb
CHANGED
|
@@ -6,6 +6,7 @@ module Textus
|
|
|
6
6
|
VERBS = {
|
|
7
7
|
# Write
|
|
8
8
|
put: Textus::Write::Put,
|
|
9
|
+
propose: Textus::Write::Propose,
|
|
9
10
|
delete: Textus::Write::Delete,
|
|
10
11
|
mv: Textus::Write::Mv,
|
|
11
12
|
accept: Textus::Write::Accept,
|
|
@@ -30,11 +31,12 @@ module Textus
|
|
|
30
31
|
pulse: Textus::Read::Pulse,
|
|
31
32
|
policy_explain: Textus::Read::PolicyExplain,
|
|
32
33
|
published: Textus::Read::Published,
|
|
33
|
-
|
|
34
|
+
schema: Textus::Read::SchemaEnvelope,
|
|
34
35
|
validate_all: Textus::Read::ValidateAll,
|
|
35
36
|
doctor: Textus::Read::Doctor,
|
|
36
37
|
boot: Textus::Read::Boot,
|
|
37
38
|
retainable: Textus::Read::Retainable,
|
|
39
|
+
rules: Textus::Read::Rules,
|
|
38
40
|
|
|
39
41
|
# Maintenance
|
|
40
42
|
migrate: Textus::Maintenance::Migrate,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Doctor
|
|
3
3
|
class Check
|
|
4
|
-
# Lists per-key fetch lock files under <root>/.locks/ whose
|
|
4
|
+
# Lists per-key fetch lock files under <root>/.run/locks/ whose
|
|
5
5
|
# recorded PID is no longer running. These are forensic artifacts only:
|
|
6
6
|
# Fetch::Lock uses flock(2), which the kernel releases on process
|
|
7
7
|
# death, so stale files do not block subsequent acquires. The check
|
|
@@ -9,7 +9,7 @@ module Textus
|
|
|
9
9
|
# (e.g. a fetch path that crashes repeatedly).
|
|
10
10
|
class FetchLocks < Check
|
|
11
11
|
def call
|
|
12
|
-
dir =
|
|
12
|
+
dir = Textus::Layout.locks(root)
|
|
13
13
|
return [] unless File.directory?(dir)
|
|
14
14
|
|
|
15
15
|
Dir.glob(File.join(dir, "*.lock")).filter_map { |path| inspect_lock(path) }
|
|
@@ -3,16 +3,13 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Domain
|
|
5
5
|
module Policy
|
|
6
|
-
# Immutable context handed to every predicate. `
|
|
6
|
+
# Immutable context handed to every predicate. `manifest` is the
|
|
7
7
|
# manifest (pure, no I/O); `envelope` is the entry under evaluation
|
|
8
8
|
# (nil when no bytes exist yet, e.g. a fresh put). `origin`/`target`
|
|
9
9
|
# are dotted keys; `transition` is the verb symbol.
|
|
10
10
|
Evaluation = Data.define(
|
|
11
|
-
:actor, :transition, :origin, :target, :envelope, :
|
|
12
|
-
)
|
|
13
|
-
def manifest = snapshot
|
|
14
|
-
def role = actor
|
|
15
|
-
end
|
|
11
|
+
:actor, :transition, :origin, :target, :envelope, :manifest
|
|
12
|
+
)
|
|
16
13
|
end
|
|
17
14
|
end
|
|
18
15
|
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -92,6 +92,10 @@ module Textus
|
|
|
92
92
|
end
|
|
93
93
|
File.write(File.join(target_root, "hooks", "README.md"), HOOKS_README)
|
|
94
94
|
File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
|
|
95
|
+
FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
|
|
96
|
+
FileUtils.mkdir_p(Textus::Layout.state(target_root))
|
|
97
|
+
FileUtils.mkdir_p(Textus::Layout.locks(target_root))
|
|
98
|
+
File.write(File.join(target_root, ".gitignore"), Textus::Layout::GITIGNORE)
|
|
95
99
|
{ "protocol" => PROTOCOL, "initialized" => target_root }
|
|
96
100
|
end
|
|
97
101
|
end
|