textus 0.49.0 → 0.50.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29a370d0ed895357e5a2e70d5f9b41542b0a4e5036472aa6f728f7a0bc459838
4
- data.tar.gz: 87bf64e383226fad74ca8534fca9f8b8de707f1e01bc910c05ad1266e5de032a
3
+ metadata.gz: 721213d52e7efc2f5bd46abf386bd099ac1d4bd3cec33d956d8a3c43b4d76018
4
+ data.tar.gz: 25695c008eb417926488b6881ad44f5867c7f56b72b721bd8b7b2d21a34eeb9e
5
5
  SHA512:
6
- metadata.gz: d3f2279c8660c754b64defe04aeac05e198095ef056b7a4aee534d3bd290baac7bf13d206999872a82c6bc61f723771f852bbfde2594fbaa17f80a19082df877
7
- data.tar.gz: e11ce9e704b3ea33ef94012d8425624dd3391152869d1ecb6b00e5422042a50d4c3c75612227ddd61f9ed8c10720e6f254c818e1492056a2ca3d00751500554b
6
+ metadata.gz: b6cbf56a3c352f9715dffafd74c0c39459766181efb97624e4aabed8cf3a32f744ea6f5fa7d620a5a61ad7e9050543bfb86d20e2d5b272e10578a5018a7ddc76
7
+ data.tar.gz: 95be6f8590727b6cc64aa52b84d456dac78c035f279af6819184a698ee75400b6a54c336008b47f93985efee42e4d9bac1909d40d5c53edfd16843f2de367cab
data/CHANGELOG.md CHANGED
@@ -9,6 +9,25 @@ 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.50.0 — 2026-06-04 — Observability on two axes + boot lifecycle (ADR 0083, 0084, 0085)
13
+
14
+ `freshness` collapses into `pulse`, the contract-drift guard stops deadlocking, and `boot` gains a lean session-start projection shipped via a plugin.
15
+
16
+ ### Added
17
+
18
+ - **`boot --lean`** (ADR 0084) — a compact orientation projection (`protocol`, `store_root`, `zones`, `agent_quickstart`, `contract_etag`) for cheap session-start injection. The full `boot` envelope now also carries `contract_etag`. `--lean` is a contract arg, so it threads to every surface (CLI flag, MCP `lean` tool arg, Ruby kwarg).
19
+ - **textus ships as a Claude Code plugin** (ADR 0084) — a single self-contained `.claude-plugin/plugin.json` with an **inline** `SessionStart` hook (`startup`/`clear`/`compact`) that runs `boot --lean`, so enabling the plugin auto-orients each session. The agent-invokable `boot` tool is unchanged; the hook is additive. Plugin data is kept inside `.claude-plugin/` (a separate concern from the gem), not a top-level `hooks/` dir.
20
+ - **Agent-integration config is textus-managed, projected from canon** (ADR 0086). This repo's `.mcp.json` and `.claude-plugin/plugin.json` are now **build artifacts** — `textus build` projects them from `knowledge.project` + `Textus::VERSION` (so the plugin version can never drift off the gem) the same way it projects `CLAUDE.md`. The plugin manifest also gains an inline `mcpServers` stanza (enable the plugin → MCP server *and* orientation hook in one). A new `provenance: false` derived-entry flag lets the JSON renderer emit config without a `_meta` block. Downstream consumers are unaffected — `init --with-agent` still scaffolds a write-once `.mcp.json` (a build must never clobber a user's config).
21
+
22
+ ### Changed
23
+
24
+ - **The contract-drift guard applies to mutating verbs only; `boot` self-heals** (ADR 0083). A mid-session manifest/hook/schema edit no longer deadlocks every MCP verb behind "re-run boot": pure reads and `boot` bypass the guard, `boot` re-arms the session's cached `contract_etag`, and only mutating verbs (the `Write::` family + the destructive `Maintenance::` verbs `tend`/`zone_mv`/`key_mv_prefix`/`key_delete_prefix`) enforce it. "Re-run boot" now actually recovers instead of looping. Refines ADR 0074.
25
+ - **`tend` returns a health summary, not full `issues[]`** (ADR 0085) — matching `pulse`; `doctor` stays the sole owner of detailed health output.
26
+
27
+ ### Removed (breaking)
28
+
29
+ - **The public `freshness` verb** (ADR 0085). Observability is now two verbs on orthogonal axes — `pulse` (transient/temporal: `changed` + `stale` + `next_due_at`) and `doctor` (structural correctness). The lifecycle scan that backed `freshness` becomes a Ruby-only internal (empty `surfaces`, ADR 0073) consumed by `pulse` and the hook context; per-entry detail is reconstructable from `get` (carries `stale`/`last_fetched_at`) + `rule_explain` (the `lifecycle:` ttl + `on_expire`). **Migration:** replace `textus freshness` with `textus pulse` (for the `stale` list / `next_due_at`) or `get` + `rule_explain` (for one entry's verdict). SPEC's generator-drift attribution is corrected to `doctor`'s `generator_drift` check.
30
+
12
31
  ## 0.49.0 — 2026-06-04 — Normalize the key-verb family + remove `migrate` (ADR 0082)
13
32
 
14
33
  The single-key mutation verbs gain the `key_` family stem, and the `migrate` orchestrator is removed.
data/SPEC.md CHANGED
@@ -398,7 +398,7 @@ No partials. No lambdas. No HTML escaping (output is raw text, intended for Mark
398
398
 
399
399
  #### 5.2.2 External compute (`kind: external`)
400
400
 
401
- A derived entry that is produced by a build tool *outside* textus — `rake`, `just`, a shell script, anything — declares `compute: { kind: external, ... }`. textus does **not** execute the command (consistent with §2); the external automation is responsible for writing the file. textus records `sources:` so `textus freshness` can compare source mtimes against the derived file's `_meta.generated.at` and report staleness.
401
+ A derived entry that is produced by a build tool *outside* textus — `rake`, `just`, a shell script, anything — declares `compute: { kind: external, ... }`. textus does **not** execute the command (consistent with §2); the external automation is responsible for writing the file. textus records `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the derived file's `_meta.generated.at` and report staleness. (Generator/build drift is dependency-based, not age-based; ADR 0079 keeps it out of the `lifecycle:` unification and ADR 0085 keeps it out of the internal `freshness` scan — it is a `doctor` health check.)
402
402
 
403
403
  ```yaml
404
404
  - key: output.catalogs.skills
@@ -415,9 +415,9 @@ A derived entry that is produced by a build tool *outside* textus — `rake`, `j
415
415
 
416
416
  **`sources:`** is a list. Each element is either a dotted key prefix (matched against manifest entries) or a filesystem path (relative to the repo root, or absolute). For each key prefix, every matching entry's file mtime is checked. For each path, file or directory mtime is checked.
417
417
 
418
- **`command:`** is recorded in the staleness row's `generator` field but never executed. It exists so `textus freshness` output can carry a hint about how to fetch.
418
+ **`command:`** is recorded in the staleness row's `generator` field but never executed. It exists so `doctor`'s `generator_drift` output can carry a hint about how to regenerate.
419
419
 
420
- **Freshness contract.** An entry with `compute: { kind: external }` is reported by `textus freshness` as `stale` when:
420
+ **Generator-drift contract.** An entry with `compute: { kind: external }` is reported by `doctor`'s `generator_drift` check as drifted when:
421
421
  - The derived file does not exist, OR
422
422
  - `_meta.generated.at` is missing or unparseable, OR
423
423
  - Any `sources:` element has been modified after `_meta.generated.at`.
@@ -498,7 +498,7 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
498
498
  **Refresh paths.** Two are supported:
499
499
 
500
500
  1. **In-process** — a read-through `textus get KEY --as=automation` on a stale entry whose rule says `on_expire: refresh` resolves the entry's `intake.handler`, invokes the registered `:resolve_intake` hook with `(caps:, config:, args: {})`, and writes the result under a role holding `fetch` (`automation` by default).
501
- 2. **External automation** — a cron job or agent harness reads `textus freshness --zone=intake --output=json`, fetches sources reported `expired` out of band, and pipes bytes back through `textus put KEY --as=automation --stdin`.
501
+ 2. **External automation** — a cron job or agent harness reads the `stale` list from `textus pulse` (the soonest deadline is `next_due_at`), fetches the sources of the keys reported stale out of band, and pipes bytes back through `textus put KEY --as=automation --stdin`. (`pulse` derives `stale`/`next_due_at` from the internal lifecycle scan; ADR 0085 removed the standalone `freshness` verb. For per-entry detail — ttl, age, on_expire action — read `textus get KEY` and `textus rule_explain KEY`.)
502
502
 
503
503
  Both paths share the same write gate, audit-log entry, and `:entry_fetched` event. User-supplied hooks live in `.textus/hooks/**/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
504
504
 
@@ -668,7 +668,7 @@ The three `:fetch_*` lifecycle events report the progress and failures of backgr
668
668
  **Signature invariant** — hooks receive a capability handle as their first keyword argument; the name depends on the mode:
669
669
 
670
670
  - **RPC hooks** (`rpc` mode) receive `caps:` — a `Textus::Container`. Event-specific kwargs (`config:`, `args:`, `rows:`) follow in the stable order shown in the table above.
671
- - **Pub-sub hooks** (`pubsub` mode) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface: `get`, `list`, `deps`, `freshness` (reads), `put`, `delete`, `audit` (authorized writes), `publish_followup`, plus `role` and `correlation_id`. The raw `Store` is not handed out.
671
+ - **Pub-sub hooks** (`pubsub` mode) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface: `get`, `list`, `deps`, `freshness` (reads — `freshness` is the Ruby-only internal lifecycle scan, ADR 0085, not a public verb), `put`, `delete`, `audit` (authorized writes), `publish_followup`, plus `role` and `correlation_id`. The raw `Store` is not handed out.
672
672
 
673
673
  Declaring `store:` instead of `caps:` in an RPC callable will pass registration but raise `UsageError` at call time (`Hooks::RpcRegistry#invoke` rejects `store:` — there is no shim).
674
674
 
@@ -865,7 +865,6 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
865
865
  | `where K` | read | any |
866
866
  | `get K [--no-fetch]` | read (read-through by default: refresh-on-stale per the entry's `lifecycle` rule when `on_expire: refresh`, degrades to a pure read; `--no-fetch` / `{fetch:false}` for an explicit pure on-disk read) | any |
867
867
  | `schema show K` | read | any |
868
- | `freshness [--prefix=K] [--zone=Z]` | read | any |
869
868
  | `audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]` | read | any |
870
869
  | `blame KEY` | read | any |
871
870
  | `rule list` / `rule explain KEY` | read | any |
@@ -902,7 +901,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
902
901
  }
903
902
  ```
904
903
 
905
- `read_verbs` is derived from the MCP verb catalog — the verbs the agent can actually call over its transport — so it lists the read/discovery verbs (`schema_show` for an entry's field shape, `rule_explain` for its freshness/guard policy, and the graph reads `where`/`deps`/`rdeps`, ADR 0060) and never the CLI-only `audit`/`freshness`/`doctor` (ADR 0056). An agent learns an entry's `_meta` shape by calling the `schema_show` verb before a `put`/`propose`, not by shelling out to a CLI. The graph reads `deps`/`rdeps` return a structured `{key, deps}`/`{key, rdeps}` envelope on every surface (CLI, Ruby, MCP) — a hash, not a bare array, consistent with the other structured read responses such as `where` (ADR 0060 amendment).
904
+ `read_verbs` is derived from the MCP verb catalog — the verbs the agent can actually call over its transport — so it lists the read/discovery verbs (`schema_show` for an entry's field shape, `rule_explain` for its lifecycle/guard policy, and the graph reads `where`/`deps`/`rdeps`, ADR 0060) and never the CLI-only `audit`/`doctor`, nor `freshness` (the Ruby-only internal lifecycle scan, ADR 0085) (ADR 0056). An agent learns an entry's `_meta` shape by calling the `schema_show` verb before a `put`/`propose`, not by shelling out to a CLI. The graph reads `deps`/`rdeps` return a structured `{key, deps}`/`{key, rdeps}` envelope on every surface (CLI, Ruby, MCP) — a hash, not a bare array, consistent with the other structured read responses such as `where` (ADR 0060 amendment).
906
905
 
907
906
  The agent's MCP write surface includes the single-key `key_delete` and `key_mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment; the single-key tools were renamed from `delete`/`mv` to share the `key_` family stem in ADR 0082, which also removed the `migrate` YAML-plan orchestrator — its `zone_mv`/`key_mv_prefix`/`key_delete_prefix` ops remain individually callable). All of these apply by default; `dry_run: true` is a uniform opt-in preview that returns a Plan without mutating (ADR 0071 — verbs are actions, dry-run is opt-in on every surface). Single-key `key_delete` additionally accepts an optional `if_etag` optimistic-concurrency check. The blast-radius reads (`where`/`deps`/`rdeps`) remain on MCP so an agent can look before it leaps. The promotion verbs `accept` and `reject` are also on MCP (ADR 0072): they are gated by the `author_held` capability floor, not by transport absence — a default-`agent` connection is refused, while a connection launched as a role holding `author` (`--as`/`TEXTUS_ROLE`/`.textus/role`, resolved once at launch per ADR 0040) can promote, closing the propose→accept loop over one transport. `build` is also on MCP (ADR 0076): it is caller-agnostic and self-elevating — it always runs as the manifest's `build`-capable actor regardless of the calling role, grants no authority over content (build is a pure, idempotent function of already-accepted canon, ADR 0070), and is serialized by a shared single-writer lock across all transports so a concurrent CLI or background build cannot collide with an MCP-triggered one.
908
907
 
@@ -923,7 +922,7 @@ The agent's MCP write surface includes the single-key `key_delete` and `key_mv`
923
922
  }
924
923
  ```
925
924
 
926
- `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. `contract_etag` is the `sha256:`-prefixed composite content hash of the contract — the manifest plus hooks and schemas (ADR 0074, via 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`.
925
+ `cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `stale` is computed by the internal lifecycle scan (the former `freshness` verb, now Ruby-only — ADR 0085 folded its agent-facing output into `pulse`). `pending_review` lists all keys in the queue zone. `doctor` is an `{ok, warn, fail}` count summary. `contract_etag` is the `sha256:`-prefixed composite content hash of the contract — the manifest plus hooks and schemas (ADR 0074, via ADR 0025) — for cheap change-detection. `next_due_at` is the soonest upcoming lifecycle 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`.
927
926
 
928
927
  **`put` input** (read from stdin when `--stdin` is given):
929
928
 
@@ -935,25 +934,7 @@ The agent's MCP write surface includes the single-key `key_delete` and `key_mv`
935
934
 
936
935
  `if_etag` is optional on both `put` and `key_delete`. When provided, the write fails with `etag_mismatch` if the on-disk file's etag differs. When omitted, the write is unconditional (last-writer-wins).
937
936
 
938
- **`textus freshness` output shape:**
939
-
940
- ```json
941
- {
942
- "verb": "freshness",
943
- "rows": [
944
- { "key": "feeds.upstream.notes",
945
- "zone": "feeds",
946
- "last_fetched_at": "2026-05-21T13:21:17Z",
947
- "age_seconds": 65000,
948
- "ttl_seconds": 43200,
949
- "on_expire": "warn",
950
- "status": "expired",
951
- "next_due_at": "2026-05-22T01:21:17Z" }
952
- ]
953
- }
954
- ```
955
-
956
- Each row reports one entry's verdict (`fresh`, `expired`, or `no_policy`) plus the matched rule's `on_expire` action, against its matched `lifecycle:` rule. `textus build` consumes its own staleness signal and executes derived entries' projections under a `build`-holding role (`automation` by default); `--dry-run` prints the plan without executing.
937
+ The lifecycle scan behind `pulse.stale`/`pulse.next_due_at` reports, per entry, one verdict (`fresh`, `expired`, or `no_policy`) plus the matched rule's `on_expire` action, against its matched `lifecycle:` rule. ADR 0085 removed the standalone `freshness` verb that used to render these rows; the scan is now Ruby-only (consumed by `pulse` and the hook context), and human drill-down into a single entry's verdict is `textus get KEY` (carries `stale`/`stale_reason`) plus `textus rule_explain KEY` (the `lifecycle:` ttl + `on_expire`). `textus build` consumes its own staleness signal and executes derived entries' projections under a `build`-holding role (`automation` by default); `--dry-run` prints the plan without executing.
957
938
 
958
939
  `textus accept K --as=human` promotes a pending entry into its target zone: it copies the patch body into the target key, deletes the pending entry, and writes one audit line per side (§audit). Only a role holding the `author` capability (the trust anchor — `human` by default) may invoke `accept`.
959
940
 
@@ -1002,7 +983,7 @@ Given a manifest entry where `key: identity.self` lives in the `identity` zone (
1002
983
  Given the `person` schema and a `put` whose frontmatter omits `relationship`, the result is the error envelope with `code: "schema_violation"`, `details.missing: ["relationship"]`, and exit code 1.
1003
984
 
1004
985
  **Fixture D — Staleness detection:**
1005
- Given a manifest entry `intake.notes` matched by a `rules: [{ match: intake.notes, lifecycle: { ttl: 1h, on_expire: warn } }]` block and an envelope on disk whose `_meta.last_fetched_at` is older than `now - ttl`, `textus freshness --output=json` includes a row for `intake.notes` with `status: "expired"`. Calling `textus freshness` does NOT trigger a refresh.
986
+ Given a manifest entry `intake.notes` matched by a `rules: [{ match: intake.notes, lifecycle: { ttl: 1h, on_expire: warn } }]` block and an envelope on disk whose `_meta.last_fetched_at` is older than `now - ttl`, `textus pulse --output=json` lists `intake.notes` in its `stale` array (the lifecycle scan classifies it `expired`). The scan is pure: producing this verdict does NOT trigger a refresh.
1006
987
 
1007
988
  **Fixture E — Projection build:**
1008
989
  Given a manifest entry `derived.catalogs.skills` whose `compute: { kind: projection }` clause selects fields from `working.projects` entries, `textus build derived.catalogs.skills` materializes the derived entry on disk with frontmatter and body matching the projected shape. The output is content-addressed (no `generated_at` timestamp, ADR 0070), so rebuilding with unchanged sources reproduces it byte-for-byte and writes nothing.
@@ -1060,7 +1041,7 @@ A `textus/3` implementation MUST:
1060
1041
  - [ ] Refuse writes whose resolved role lacks the capability the target zone-kind requires with `write_forbidden`.
1061
1042
  - [ ] Return envelopes matching the shape in §8 exactly (with `_meta`, not `frontmatter`).
1062
1043
  - [ ] Use the error codes in §8 and the exit-code table.
1063
- - [ ] Implement `textus freshness` per §5.1 and §9, walking each entry, matching it against the top-level `rules:` block, and reporting `fresh|expired|no_policy` (plus the `on_expire` action) without invoking any refresh.
1044
+ - [ ] Implement the lifecycle scan behind `pulse` (`stale`/`next_due_at`) and the hook context per §5.11 and §9, walking each entry, matching it against the top-level `rules:` block, and reporting `fresh|expired|no_policy` (plus the `on_expire` action) without invoking any refresh. (ADR 0085: a Ruby-only internal scan — there is no public `freshness` verb.)
1064
1045
  - [ ] Pass the conformance fixtures A–I in §12.
1065
1046
 
1066
1047
  A `textus/3` implementation MAY:
@@ -69,7 +69,7 @@ A derived entry declares a `compute:` block with a `kind:` discriminator. Two ki
69
69
  to: [docs/people.md] # optional repo-relative byte-copy targets
70
70
  ```
71
71
 
72
- **`compute: { kind: external }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `textus freshness` can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `build` (default: `automation`).
72
+ **`compute: { kind: external }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `build` (default: `automation`).
73
73
 
74
74
  ```yaml
75
75
  - key: artifacts.catalogs.skills
@@ -92,7 +92,7 @@ External inputs land via `:resolve_intake` hooks, not shell commands. Each intak
92
92
 
93
93
  ```sh
94
94
  textus get feeds.notion.roadmap --as=automation # refreshes if stale
95
- textus freshness --zone=feeds --output=json # which entries are expired
95
+ textus pulse --output=json # `stale` lists expired entries; `next_due_at` is the soonest deadline
96
96
  ```
97
97
 
98
98
  Lifecycle budgets live in the top-level `rules:` block, matched by glob:
@@ -146,4 +146,4 @@ The user-facing CLI surface, the wire envelope shape, and the protocol version (
146
146
 
147
147
  - **MCP servers**: a thin server that exposes `textus get` and `textus put` as tools is the recommended way to give Claude/agents access. Don't bake MCP into this gem.
148
148
  - **Vector stores**: index `body` content into a vector store if you want fuzzy retrieval. `frontmatter` stays in textus as the source of truth for deterministic facts.
149
- - **CI**: run `textus freshness` (or `textus list` + schema validation) in CI to catch drift between derived entries and their sources.
149
+ - **CI**: run `textus doctor` (the `generator_drift` check) or `textus pulse` (the `stale` list) in CI to catch drift between derived entries and their sources.
data/lib/textus/boot.rb CHANGED
@@ -71,49 +71,50 @@ module Textus
71
71
  },
72
72
  }.freeze
73
73
 
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.
74
+ # Curated agent-facing verb catalog. This declares which verbs the operator
75
+ # CLI surfaces and in what order the editorial presentation. The summary of
76
+ # each verb is a fact, not presentation: it is derived from `contract.summary`
77
+ # at load time (ADR 0039). A literal "summary" survives here only for grouped
78
+ # CLI tokens (schema/key/rule/hook) that aggregate several sub-contracts and so
79
+ # have no single contract to derive from. CLI_VERBS itself is assigned in
80
+ # textus.rb after Zeitwerk eager_load so all contract files are present.
79
81
  CURATED_CLI_VERBS = [
80
82
  { "name" => "boot" },
81
83
  { "name" => "list" },
82
84
  { "name" => "get" },
83
- { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
85
+ { "name" => "where" },
84
86
  { "name" => "schema", "summary" => "schema operations: 'schema show KEY', 'schema diff', 'schema init', 'schema migrate'" },
85
87
  { "name" => "put" },
86
88
  { "name" => "propose" },
87
- { "name" => "accept", "summary" => "apply a queued proposal to its target zone; requires the author capability" },
88
- { "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
89
- { "name" => "build", "summary" => "materialize derived entries; publish_to and publish_tree fan out copies" },
89
+ { "name" => "accept" },
90
+ { "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
91
+ { "name" => "build" },
90
92
  { "name" => "tend" },
91
- { "name" => "freshness", "summary" => "per-entry lifecycle report (status, age, ttl, on_expire action)" },
92
- { "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
93
- { "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
94
- { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
95
- { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
96
- { "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
93
+ { "name" => "audit" },
94
+ { "name" => "blame" },
95
+ { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
96
+ { "name" => "doctor" },
97
+ { "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
97
98
  { "name" => "pulse" },
98
99
  { "name" => "capabilities" },
99
100
  ].freeze
100
101
 
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] }
102
+ # verb token => contract.summary, for every Dispatcher verb that carries a
103
+ # contract. The single source for a verb's one-line summary (ADR 0039).
104
+ def self.contract_summaries
105
+ Dispatcher::VERBS.values
106
+ .select { |k| k.respond_to?(:contract?) && k.contract? }
107
+ .to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
108
+ end
109
109
 
110
+ # Build the CLI verb catalog: each summary is derived from its contract when
111
+ # one exists, falling back to the curated editorial string for grouped tokens
112
+ # (schema/key/rule/hook). Called once from textus.rb after eager_load.
113
+ def self.build_cli_verbs
114
+ summaries = contract_summaries
110
115
  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
116
+ derived = summaries[entry["name"]]
117
+ derived ? entry.merge("summary" => derived) : entry
117
118
  end
118
119
  end
119
120
 
@@ -130,7 +131,8 @@ module Textus
130
131
  # Both verb lists derive from the MCP catalog (ADR 0056, ADR 0057): the
131
132
  # agent's real read and write surface, named as verbs the agent calls —
132
133
  # not CLI strings. read_verbs can neither advertise a verb the agent
133
- # cannot call (audit/freshness/doctor are CLI-only) nor omit one it can
134
+ # cannot call (audit/doctor are CLI-only; freshness is a Ruby-only
135
+ # internal scan, ADR 0085) nor omit one it can
134
136
  # (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
135
137
  # framing (role is connection-resolved over MCP; there is no stdin).
136
138
  # writable_zones / propose_zone below carry the agent's write authority.
@@ -196,8 +198,20 @@ module Textus
196
198
  )
197
199
  end
198
200
 
199
- def self.build(container:)
201
+ def self.build(container:, lean: false)
200
202
  manifest = container.manifest
203
+ etag = Textus::Etag.for_contract(container.root)
204
+
205
+ if lean
206
+ return {
207
+ "protocol" => PROTOCOL_ID,
208
+ "store_root" => container.root,
209
+ "zones" => zones_for(manifest),
210
+ "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
211
+ "contract_etag" => etag,
212
+ }
213
+ end
214
+
201
215
  {
202
216
  "protocol" => PROTOCOL_ID,
203
217
  "store_root" => container.root,
@@ -208,6 +222,7 @@ module Textus
208
222
  "cli_verbs" => CLI_VERBS.map(&:dup),
209
223
  "agent_protocol" => agent_protocol(manifest),
210
224
  "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
225
+ "contract_etag" => etag,
211
226
  "docs" => { "spec" => "SPEC.md", "example" => "examples/project/" },
212
227
  }
213
228
  end
@@ -6,7 +6,7 @@ module Textus
6
6
  class Json < Renderer
7
7
  def call(mentry:, data:)
8
8
  content = mentry.template ? parse_rendered_template!(mentry, data) : default_shape(mentry, data)
9
- final = InjectMeta.call(content, mentry)
9
+ final = mentry.provenance ? InjectMeta.call(content, mentry) : content
10
10
  Entry.for_format("json").serialize(meta: {}, body: "", content: final)
11
11
  end
12
12
 
@@ -3,9 +3,10 @@ module Textus
3
3
  class Verb
4
4
  class Boot < Verb
5
5
  command_name "boot"
6
+ option :lean, "--lean"
6
7
 
7
8
  def call(store)
8
- emit(store.boot)
9
+ emit(store.boot(lean: !!lean))
9
10
  end
10
11
  end
11
12
  end
data/lib/textus/cli.rb CHANGED
@@ -123,7 +123,6 @@ module Textus
123
123
  textus where KEY
124
124
  textus get KEY
125
125
  textus put KEY --stdin [--fetch=NAME] --as=ROLE
126
- textus freshness [--prefix=KEY] [--zone=Z]
127
126
  textus fetch KEY
128
127
  textus fetch all [--prefix=KEY] [--zone=Z]
129
128
  textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
@@ -42,6 +42,7 @@ module Textus
42
42
  # without `respond_to?` guards.
43
43
  def template = nil
44
44
  def inject_boot = false # rubocop:disable Naming/PredicateMethod
45
+ def provenance = true # rubocop:disable Naming/PredicateMethod
45
46
  def events = {}
46
47
  def publish_tree = nil
47
48
  def ignore = []
@@ -5,13 +5,14 @@ module Textus
5
5
  Projection = ::Data.define(:select, :pluck, :sort_by, :transform)
6
6
  External = ::Data.define(:sources, :command)
7
7
 
8
- attr_reader :source, :template, :inject_boot, :events
8
+ attr_reader :source, :template, :inject_boot, :provenance, :events
9
9
 
10
- def initialize(source:, template: nil, inject_boot: false, events: {}, **rest)
10
+ def initialize(source:, template: nil, inject_boot: false, provenance: true, events: {}, **rest)
11
11
  super(**rest)
12
12
  @source = source
13
13
  @template = template
14
14
  @inject_boot = inject_boot
15
+ @provenance = provenance
15
16
  @events = events || {}
16
17
  end
17
18
 
@@ -53,6 +54,7 @@ module Textus
53
54
  source: source,
54
55
  template: raw["template"],
55
56
  inject_boot: raw["inject_boot"] == true,
57
+ provenance: raw.fetch("provenance", true) != false,
56
58
  events: raw["events"] || {},
57
59
  **common,
58
60
  )
@@ -25,7 +25,7 @@ module Textus
25
25
  ENTRY_KEYS = %w[
26
26
  key path zone kind schema owner nested format
27
27
  compute template publish
28
- intake events inject_boot ignore tracked
28
+ intake events inject_boot provenance ignore tracked
29
29
  ].freeze
30
30
  # ADR 0052: the typed publish block — `publish: { to: [...] }` (file
31
31
  # fan-out) xor `publish: { tree: "dir" }` (subtree mirror).
@@ -89,12 +89,19 @@ module Textus
89
89
  return
90
90
  end
91
91
 
92
- @session.check_etag!(contract_etag)
93
-
94
92
  name = params["name"]
95
93
  args = params["arguments"] || {}
94
+
95
+ # ADR 0083: the contract-drift guard gates mutating verbs — every MCP
96
+ # verb that is NOT a pure read (Write:: + the destructive Maintenance::
97
+ # verbs tend/zone_mv/key_*_prefix). Reads and boot bypass it (a stale
98
+ # read returns on-disk truth; boot re-orients). Keying on read_verbs
99
+ # (not write_verbs) keeps the destructive Maintenance:: verbs gated.
100
+ @session.check_etag!(contract_etag) unless Catalog.read_verbs.include?(name)
101
+
96
102
  result = Catalog.call(name, session: @session, store: @store, args: args)
97
103
  @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
104
+ @session = @session.with(contract_etag: contract_etag) if name == "boot"
98
105
 
99
106
  emit_result(rid, {
100
107
  "content" => [{ "type" => "text", "text" => JSON.dump(result) }],
@@ -10,14 +10,16 @@ module Textus
10
10
  verb :boot
11
11
  summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
12
12
  surfaces :cli, :mcp
13
+ arg :lean, :boolean,
14
+ description: "return only orientation essentials (zones, agent_quickstart, contract_etag) for cheap session-start injection"
13
15
 
14
16
  def initialize(container:, call:)
15
17
  @container = container
16
18
  @call = call
17
19
  end
18
20
 
19
- def call
20
- Textus::Boot.build(container: @container)
21
+ def call(lean: false)
22
+ Textus::Boot.build(container: @container, lean: lean)
21
23
  end
22
24
  end
23
25
  end
@@ -2,20 +2,24 @@ require "time"
2
2
 
3
3
  module Textus
4
4
  module Read
5
- # Per-entry lifecycle report (ADR 0079). Walks every entry declared in the
6
- # manifest, consults `rules.for(key)` for a `lifecycle:` policy, and reports
7
- # the unified verdict. Status is one of :fresh, :expired, or :no_policy; the
8
- # row also carries the policy's :action (on_expire).
5
+ # Per-entry lifecycle scan (ADR 0079, 0085). Walks every entry declared in
6
+ # the manifest, consults `rules.for(key)` for a `lifecycle:` policy, and
7
+ # reports the unified verdict. Status is one of :fresh, :expired, or
8
+ # :no_policy; the row also carries the policy's :action (on_expire).
9
+ #
10
+ # ADR 0085 removed the public `freshness` verb: there is no `:cli`/`:mcp`
11
+ # surface. This is now a Ruby-only internal scan (empty `surfaces`, the
12
+ # honest home reserved by ADR 0073) consumed by `pulse` (which derives
13
+ # `stale` + `next_due_at` from it) and the hook `Context`. Humans drill
14
+ # into per-entry lifecycle detail via `get` (last_fetched_at) + `rule_explain`
15
+ # (the ttl / on_expire policy) instead of a dedicated verb.
9
16
  class Freshness
10
17
  extend Textus::Contract::DSL
11
18
 
12
19
  verb :freshness
13
- summary "Report the fetch-freshness status of every entry with a fetch policy."
14
- surfaces :cli
15
- cli "freshness"
20
+ summary "Internal per-entry lifecycle scan (status, age, ttl, on_expire); backs pulse + hook context. No public surface (ADR 0085)."
16
21
  arg :prefix, String, required: false, description: "filter to keys with this prefix"
17
22
  arg :zone, String, required: false, description: "filter to entries in this zone"
18
- view(:cli) { |rows| { "verb" => "freshness", "rows" => rows } }
19
23
 
20
24
  def initialize(container:, call:)
21
25
  @container = container
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.49.0"
2
+ VERSION = "0.50.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.49.0
4
+ version: 0.50.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick