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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -2
  3. data/README.md +34 -13
  4. data/SPEC.md +10 -4
  5. data/lib/textus/boot.rb +41 -21
  6. data/lib/textus/cli/verb/mcp_serve.rb +8 -3
  7. data/lib/textus/cli/verb/propose.rb +28 -0
  8. data/lib/textus/cli/verb/pulse.rb +12 -3
  9. data/lib/textus/cli/verb/schema.rb +1 -1
  10. data/lib/textus/cli/verb.rb +3 -2
  11. data/lib/textus/contract.rb +106 -0
  12. data/lib/textus/cursor_store.rb +24 -0
  13. data/lib/textus/dispatcher.rb +3 -1
  14. data/lib/textus/doctor/check/audit_log.rb +1 -1
  15. data/lib/textus/doctor/check/fetch_locks.rb +2 -2
  16. data/lib/textus/doctor/check/illegal_keys.rb +10 -4
  17. data/lib/textus/domain/policy/evaluation.rb +3 -6
  18. data/lib/textus/init.rb +4 -0
  19. data/lib/textus/layout.rb +41 -0
  20. data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
  21. data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
  22. data/lib/textus/maintenance/migrate.rb +9 -0
  23. data/lib/textus/maintenance/rule_lint.rb +8 -0
  24. data/lib/textus/maintenance/zone_mv.rb +10 -0
  25. data/lib/textus/manifest/entry/base.rb +5 -0
  26. data/lib/textus/manifest/entry/ignore_matcher.rb +46 -0
  27. data/lib/textus/manifest/entry/nested.rb +9 -2
  28. data/lib/textus/manifest/entry/validators/ignore.rb +28 -0
  29. data/lib/textus/manifest/entry/validators.rb +1 -0
  30. data/lib/textus/manifest/resolver.rb +2 -0
  31. data/lib/textus/manifest/schema.rb +1 -1
  32. data/lib/textus/mcp/catalog.rb +72 -0
  33. data/lib/textus/mcp/server.rb +8 -5
  34. data/lib/textus/mcp/session.rb +3 -20
  35. data/lib/textus/mcp/tool_schemas.rb +6 -62
  36. data/lib/textus/mcp/tools.rb +4 -119
  37. data/lib/textus/ports/audit_log.rb +17 -15
  38. data/lib/textus/ports/build_lock.rb +1 -2
  39. data/lib/textus/ports/fetch/lock.rb +1 -1
  40. data/lib/textus/read/audit.rb +3 -3
  41. data/lib/textus/read/boot.rb +6 -0
  42. data/lib/textus/read/get.rb +8 -0
  43. data/lib/textus/read/list.rb +8 -0
  44. data/lib/textus/read/pulse.rb +7 -0
  45. data/lib/textus/read/rules.rb +24 -0
  46. data/lib/textus/read/schema_envelope.rb +7 -0
  47. data/lib/textus/role.rb +6 -2
  48. data/lib/textus/session.rb +24 -0
  49. data/lib/textus/store.rb +11 -0
  50. data/lib/textus/version.rb +1 -1
  51. data/lib/textus/write/accept.rb +1 -1
  52. data/lib/textus/write/delete.rb +1 -1
  53. data/lib/textus/write/fetch_all.rb +8 -0
  54. data/lib/textus/write/fetch_worker.rb +9 -1
  55. data/lib/textus/write/mv.rb +1 -1
  56. data/lib/textus/write/propose.rb +46 -0
  57. data/lib/textus/write/put.rb +13 -1
  58. data/lib/textus/write/reject.rb +1 -1
  59. data/lib/textus.rb +4 -0
  60. metadata +15 -5
  61. data/docs/conventions.md +0 -148
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3e6ac596658c63369ce977d194a2c43d0a789cdfa97b04d100b70e572165e63
4
- data.tar.gz: c3ed7085fa38777fdc6e8a38e8c4c8f4a51ce113065f24b42f596aa220acb9f3
3
+ metadata.gz: 15b2e8bcfb3e83425617ee187705b88ad99c1f96671a7ad04a8394073f98065d
4
+ data.tar.gz: e0711f287c8739fcc2e5f9507fd5637b9c63159b2d8e364f9d65f338138ff432
5
5
  SHA512:
6
- metadata.gz: a95c23aaf19216505f272f0262b66e1e7a3e01b3e78a2b8062ab827a3c8109a6d085348b3e1b1e664a41f0bbdeaf46cf736b47fe211495905445c9ae283c88fa
7
- data.tar.gz: 453327b6cea08e0102e76dd22723d6f77a69d10f8d6810427e23934956d5a332b5f9d33b81d568bf7529cf8ab83bdfb6b323b447d2d9dead6d06c2b703e66ac4
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
- ## 0.35.1 — 2026-05-31 — RSpec foundation consolidation (test-only)
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 -->|human accepts| knowledge
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
- - **5-command worked demo** — single terminal scroll, no MCP, no schemas: [`examples/hello/`](examples/hello/)
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 the full shapeClaude plugin with agents, skills, commands, pending walkthrough, intake action — see [`examples/claude-plugin/`](examples/claude-plugin/).
168
+ For a worked storeknowledge 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`.** 15 health checks across schemas, hooks, keys, sentinels, and the audit log.
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 a tutorial expansion in [`docs/zones.md`](docs/zones.md).
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/claude-plugin/`](examples/claude-plugin/) — a Claude Code plugin (`voice-tools`) whose entire content surface agents, skills, commands, `CLAUDE.md`, `plugin.json`, `marketplace.json` — is textus-managed. Demonstrates per-entry formats, `publish_each`, intake actions, in-process transforms and hooks, the agent-propose / human-accept loop, and the `inject_boot:` flag that puts an orientation preamble at the top of `CLAUDE.md`.
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
- ~920 examples; includes conformance fixtures A–I from SPEC §12.
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/events.md`](docs/events.md).
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
- # The CLI verb catalog. Truth lives here; do not derive dynamically.
75
- # Agents that read boot should see a stable shape regardless of how
76
- # verb implementations evolve.
77
- CLI_VERBS = [
78
- { "name" => "boot", "summary" => "this output orientation for agents and tools" },
79
- { "name" => "list", "summary" => "enumerate keys (optional --prefix)" },
80
- { "name" => "get", "summary" => "read an entry; envelope with _meta, body, uid, etag" },
81
- { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
82
- { "name" => "schema", "summary" => "field shape for a key family" },
83
- { "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
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", "summary" => "run an action for a quarantine entry" },
91
+ { "name" => "fetch" },
89
92
  { "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
90
- { "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
91
- { "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
92
- { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
93
- { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
94
- { "name" => "hook",
95
- "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
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/claude-plugin/" },
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
- # stdin; never returns until stdin closes.
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
- Textus::MCP::Server.new(store: store, stdin: @stdin, stdout: @stdout).run
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
- ops = session_for(store)
11
- since_n = (since || "0").to_i
12
- emit(ops.pulse(since: since_n))
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
@@ -7,7 +7,7 @@ module Textus
7
7
 
8
8
  def call(store)
9
9
  key = positional.shift or raise UsageError.new("schema requires a key")
10
- emit(session_for(store).schema_envelope(key))
10
+ emit(session_for(store).schema(key))
11
11
  end
12
12
  end
13
13
  end
@@ -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
- def resolved_role(store)
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