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 +4 -4
- data/CHANGELOG.md +19 -0
- data/SPEC.md +10 -29
- data/docs/reference/conventions.md +3 -3
- data/lib/textus/boot.rb +46 -31
- data/lib/textus/builder/renderer/json.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +2 -1
- data/lib/textus/cli.rb +0 -1
- data/lib/textus/manifest/entry/base.rb +1 -0
- data/lib/textus/manifest/entry/derived.rb +4 -2
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/mcp/server.rb +9 -2
- data/lib/textus/read/boot.rb +4 -2
- data/lib/textus/read/freshness.rb +12 -8
- data/lib/textus/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 721213d52e7efc2f5bd46abf386bd099ac1d4bd3cec33d956d8a3c43b4d76018
|
|
4
|
+
data.tar.gz: 25695c008eb417926488b6881ad44f5867c7f56b72b721bd8b7b2d21a34eeb9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
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 `
|
|
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
|
-
**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
|
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.
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
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"
|
|
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"
|
|
88
|
-
{ "name" => "key",
|
|
89
|
-
{ "name" => "build"
|
|
89
|
+
{ "name" => "accept" },
|
|
90
|
+
{ "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
|
|
91
|
+
{ "name" => "build" },
|
|
90
92
|
{ "name" => "tend" },
|
|
91
|
-
{ "name" => "
|
|
92
|
-
{ "name" => "
|
|
93
|
-
{ "name" => "
|
|
94
|
-
{ "name" => "
|
|
95
|
-
{ "name" => "
|
|
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
|
-
#
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 =
|
|
112
|
-
|
|
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/
|
|
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
|
|
data/lib/textus/cli/verb/boot.rb
CHANGED
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).
|
data/lib/textus/mcp/server.rb
CHANGED
|
@@ -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) }],
|
data/lib/textus/read/boot.rb
CHANGED
|
@@ -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
|
|
6
|
-
# manifest, consults `rules.for(key)` for a `lifecycle:` policy, and
|
|
7
|
-
# the unified verdict. Status is one of :fresh, :expired, or
|
|
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 "
|
|
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
|
data/lib/textus/version.rb
CHANGED