textus 0.43.2 → 0.45.1
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 +57 -0
- data/README.md +3 -3
- data/SPEC.md +15 -13
- data/docs/architecture/README.md +28 -9
- data/docs/reference/conventions.md +8 -9
- data/lib/textus/boot.rb +3 -4
- data/lib/textus/cli/group/fetch.rb +2 -2
- data/lib/textus/cli/group.rb +1 -0
- data/lib/textus/cli/runner.rb +187 -0
- data/lib/textus/cli/verb/build.rb +4 -4
- data/lib/textus/cli/verb/{fetch_stale.rb → fetch_all.rb} +2 -2
- data/lib/textus/cli/verb/get.rb +6 -5
- data/lib/textus/cli/verb/put.rb +3 -3
- data/lib/textus/cli/verb.rb +3 -0
- data/lib/textus/cli.rb +8 -2
- data/lib/textus/contract/around.rb +29 -0
- data/lib/textus/contract/binder.rb +88 -0
- data/lib/textus/contract/resources/cursor.rb +26 -0
- data/lib/textus/contract/sources.rb +39 -0
- data/lib/textus/contract/view.rb +15 -0
- data/lib/textus/contract.rb +68 -8
- data/lib/textus/dispatcher.rb +5 -6
- data/lib/textus/hooks/context.rb +24 -2
- data/lib/textus/maintenance/key_delete_prefix.rb +6 -4
- data/lib/textus/maintenance/key_mv_prefix.rb +7 -5
- data/lib/textus/maintenance/migrate.rb +12 -9
- data/lib/textus/maintenance/rule_lint.rb +4 -3
- data/lib/textus/maintenance/zone_mv.rb +7 -5
- data/lib/textus/manifest/entry/base.rb +1 -1
- data/lib/textus/mcp/catalog.rb +6 -33
- data/lib/textus/projection.rb +2 -2
- data/lib/textus/read/audit.rb +19 -0
- data/lib/textus/read/blame.rb +11 -1
- data/lib/textus/read/deps.rb +15 -1
- data/lib/textus/read/doctor.rb +8 -0
- data/lib/textus/read/freshness.rb +10 -0
- data/lib/textus/read/get.rb +86 -21
- data/lib/textus/read/list.rb +1 -0
- data/lib/textus/read/published.rb +7 -0
- data/lib/textus/read/pulse.rb +1 -0
- data/lib/textus/read/rdeps.rb +14 -0
- data/lib/textus/read/rule_explain.rb +84 -0
- data/lib/textus/read/rule_list.rb +39 -0
- data/lib/textus/read/schema_envelope.rb +3 -2
- data/lib/textus/read/uid.rb +9 -0
- data/lib/textus/read/where.rb +8 -0
- data/lib/textus/role_scope.rb +34 -6
- data/lib/textus/schema/tools.rb +12 -3
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +8 -0
- data/lib/textus/write/{publish.rb → build.rb} +16 -7
- data/lib/textus/write/delete.rb +13 -0
- data/lib/textus/write/fetch_all.rb +1 -0
- data/lib/textus/write/fetch_orchestrator.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +1 -1
- data/lib/textus/write/mv.rb +16 -0
- data/lib/textus/write/propose.rb +7 -2
- data/lib/textus/write/put.rb +2 -2
- data/lib/textus/write/reject.rb +8 -0
- data/lib/textus/write/retention_sweep.rb +9 -0
- metadata +11 -29
- data/lib/textus/cli/verb/accept.rb +0 -16
- data/lib/textus/cli/verb/audit.rb +0 -34
- data/lib/textus/cli/verb/blame.rb +0 -17
- data/lib/textus/cli/verb/delete.rb +0 -17
- data/lib/textus/cli/verb/deps.rb +0 -14
- data/lib/textus/cli/verb/freshness.rb +0 -17
- data/lib/textus/cli/verb/key_delete.rb +0 -24
- data/lib/textus/cli/verb/list.rb +0 -16
- data/lib/textus/cli/verb/migrate.rb +0 -18
- data/lib/textus/cli/verb/mv.rb +0 -27
- data/lib/textus/cli/verb/propose.rb +0 -28
- data/lib/textus/cli/verb/published.rb +0 -13
- data/lib/textus/cli/verb/pulse.rb +0 -26
- data/lib/textus/cli/verb/rdeps.rb +0 -14
- data/lib/textus/cli/verb/reject.rb +0 -16
- data/lib/textus/cli/verb/retain.rb +0 -19
- data/lib/textus/cli/verb/rule_explain.rb +0 -16
- data/lib/textus/cli/verb/rule_lint.rb +0 -18
- data/lib/textus/cli/verb/rule_list.rb +0 -29
- data/lib/textus/cli/verb/schema.rb +0 -15
- data/lib/textus/cli/verb/uid.rb +0 -15
- data/lib/textus/cli/verb/where.rb +0 -14
- data/lib/textus/cli/verb/zone_mv.rb +0 -19
- data/lib/textus/read/get_or_fetch.rb +0 -69
- data/lib/textus/read/policy_explain.rb +0 -46
- data/lib/textus/read/rules.rb +0 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dfb215839c07335a72f604bbcb830d6027a118f500be30243a592cc9c5b07cf0
|
|
4
|
+
data.tar.gz: dd0103470acbdfb747077bc6d0fafbc08700edfe5a24d09416723d47517871df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 310065adbf501efcd2b793b896770a9cc7e5a38e41c23852c12b18dfca5cc93cea7af1545bac5e306a667aeac959995c9cf08468a3d86869facec42d11c1501f
|
|
7
|
+
data.tar.gz: 938e0ed014c543d2cba7190ab6de0440a36670268b4207af19444934fb28560cda382452c0cb1db26e4c31bfe78b89031c52fbdb0196fc7d67eb360013d58d5f
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,63 @@ 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.45.1 — 2026-06-03 — Single-path lifecycle: kill the last dual-paths ([ADR 0069](docs/architecture/decisions/0069-single-path-lifecycle.md))
|
|
13
|
+
|
|
14
|
+
No `textus/3` wire-format change. Finishes the 0.45.0 lifecycle (ADRs 0066–0068): the request path was single-path in shape but still carried four residual dual-paths. This removes all four so the lifecycle — `normalize → bind (always validate) → dispatch (+around) → view (self-shaping)` — is single-path in fact on every surface. Three breaking (pre-1.0) changes, all accepted.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Views self-shape on every surface.** The CLI runner's `result.to_h_for_wire` pre-wire is deleted, so every `view` receives the raw use-case result on every surface (the pattern `get`/`zone_mv`/`migrate` already used). `propose` collapses its two views into one `view { |env, _i| env.to_h_for_wire }`.
|
|
19
|
+
- **One normalizer home.** MCP's inline by-wire-name normalizer is lifted into `Contract::Binder.inputs_from_wire`, beside `inputs_from_ordered`; the binder now owns both. `MCP::Catalog#call` calls it.
|
|
20
|
+
- **Validation is unconditional; `required:` is an honest invariant.** The `validate:` keyword is dropped from `Binder.bind`, `RoleScope#dispatch_bound`, and `Maintenance::Migrate`. Bind always validates — no opt-out, so the `validate: false`-by-default footgun cannot exist. `required:` now means "required on every surface," not an agent-wire policy (retiring the [ADR 0066](docs/architecture/decisions/0066-one-binder-required-is-a-surface-policy.md) `validate:` fork).
|
|
21
|
+
- **The hand-authored CLI taxonomy is named and guarded.** `HAND_AUTHORED_VERBS` splits into `BEHAVIORAL_HATCHES` (`get`, `put`, `build` — genuine `< Runner::Base` overrides) and `NON_PROJECTED_CLI` (`fetch`, `fetch_all`, `boot`, `doctor` — plain `< Verb` commands), with the union derived. A new guard spec asserts each member's CLI class matches its category.
|
|
22
|
+
|
|
23
|
+
### Breaking (pre-1.0)
|
|
24
|
+
|
|
25
|
+
- **`Binder.bind` / `RoleScope#dispatch_bound` drop the `validate:` keyword.** Callers passing it must remove it; validation is always on.
|
|
26
|
+
- **`put`/`propose` `meta` is now `required: false`.** A missing `_meta` no longer returns a pre-dispatch `missing _meta` error — it binds with `meta` absent and flows to schema validation downstream (where an *invalid* `_meta` fails, and an absent-but-schema-permitted `_meta` succeeds). `meta`'s real requiredness lives in schema validation.
|
|
27
|
+
- **MCP/Ruby `propose` returns the full wire envelope.** The single self-shaping view emits `env.to_h_for_wire` on every surface — a superset of the old `{uid, etag, key}`.
|
|
28
|
+
|
|
29
|
+
## 0.45.0 — 2026-06-03 — The contract owns the request lifecycle ([ADRs 0066–0068](docs/architecture/decisions/))
|
|
30
|
+
|
|
31
|
+
No `textus/3` wire-format change. The `Contract` now owns the entire request lifecycle — `acquire → bind → invoke → render` — so the three surfaces (MCP, CLI, Ruby) project from it with no re-implemented argument-mapping, no dual response/cli_response shaping, and no behavioral escape-hatch classes that exist only because the contract couldn't express I/O acquisition, a stateful wrapper, or a coercion. The escape-hatch population shrinks from 18 to the irreducible behavioral floor of 7. Several breaking (pre-1.0) DSL and signature changes; one operator-visible CLI change (`key delete-prefix` / `key mv-prefix`).
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- **One argument binder; `required:` is a surface policy ([ADR 0066](docs/architecture/decisions/0066-one-binder-required-is-a-surface-policy.md)).** `MCP::Catalog.map_args`, `CLI::Runner.call_args`, and `RoleScope`'s default-injection loop collapse into one `Contract::Binder.bind` over a uniform by-name `inputs` hash. Every surface dispatches through one site, `RoleScope#dispatch_bound`, so bind fires exactly once. The agent surfaces validate required args (`validate: true`); the Ruby API binds leniently and trusts the use-case's own keyword defaults — `required:` is an agent-wire policy, not a contract invariant.
|
|
36
|
+
- **BREAKING (pre-1.0): per-surface `view`s replace `response`/`cli_response` ([ADR 0067](docs/architecture/decisions/0067-per-surface-views.md)).** One `views` map keyed by surface: `view { … }` (MCP/Ruby) and `view(:cli) { … }`. Every view is called uniformly as `(result, inputs)`, retiring the `Proc#arity == 2` sniff. `Contract::View.render` is the single shaping entry point.
|
|
37
|
+
- **Declarative facets dissolve the acquisition & wrapper hatches ([ADR 0068](docs/architecture/decisions/0068-declarative-facets-dissolve-escape-hatches.md)).** `arg … source: :file` (read a path → contents), `arg … coerce: callable`, `cli_stdin :json` (stdin envelope), `around :name` (stateful wrappers via `Contract::Around`), and `arg … cli_default:` (a CLI default diverging from the agent default, driving both the value and boolean flag polarity). `propose`, `migrate`, `rule_lint`, `audit`, `zone_mv`, `pulse`, `key_delete`, and `mv` lose their hand-authored CLI classes and generate. `HAND_AUTHORED_VERBS` shrinks 18 → 7 (`get`, `put`, `build`, `fetch`, `fetch_all`, `boot`, `doctor`) — the behavioral floor. `zone_mv`/`migrate`/`key_*_prefix` apply by default on the CLI and plan by default for agents (ADR 0060), now legible in the contract rather than hidden in a hand class.
|
|
38
|
+
- **BREAKING (pre-1.0): `key delete --prefix P` → `key delete-prefix P`; `key mv --prefix F T` → `key mv-prefix F T`.** The two `--prefix` overloads split into first-class generated commands, each dispatching the verb its own contract names.
|
|
39
|
+
- **BREAKING (pre-1.0): positional `#call` signatures.** `zone_mv` (`from`/`to`), `migrate` (`plan_yaml`), `key_mv_prefix` (`from_prefix`/`to_prefix`), and `key_delete_prefix` (`prefix`) take their declared-positional args positionally, matching the CLI and the binder.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **`Runner.coerce` now coerces `Integer`-typed flags.** A `case … when Integer` used `===` (instance-of), so `Integer`-typed flags (`audit --limit`/`--seq-since`) silently stayed strings; now compared by equality.
|
|
44
|
+
- **The CLI reconciliation guards are seed-independent.** `CLI.verbs` and the contract-reconciliation check ignore anonymous (`name.nil?`) test-fixture `Verb` subclasses that previously leaked into the registry under some RSpec seeds.
|
|
45
|
+
|
|
46
|
+
## 0.44.1 — 2026-06-03 — Finish the CLI contract projection ([ADR 0065](docs/architecture/decisions/0065-finish-cli-response-shrink-escape-hatches.md))
|
|
47
|
+
|
|
48
|
+
No `textus/3` wire-format change. The `cli_response` facet can now receive the call's resolved inputs, so the two output-only escape hatches become generated verbs. No operator-visible change to CLI commands, flags, or output.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- **`cli_response` may see the call inputs; `uid` and `blame` are now generated ([ADR 0065](docs/architecture/decisions/0065-finish-cli-response-shrink-escape-hatches.md)).** An arity-2 `cli_response` lambda receives `(result, inputs)` (inputs keyed by arg name), letting an envelope echo an input such as the key. `Read::Uid` and `Read::Blame` declare their CLI envelopes in their contracts and drop their hand-authored `Runner::Base` subclasses — the escape-hatch population shrinks from 13 to 11. `zone_mv` (CLI applies by default, agents plan by default — ADR 0060) and `audit` (a one-off `since` coercion) deliberately stay hand-authored.
|
|
53
|
+
- **BREAKING (pre-1.0): `Read::Blame#call` takes the key positionally.** `store.as(role).blame(key:)` becomes `store.as(role).blame(key)` to match the CLI's positional `blame KEY` and the generated arg-mapping. The CLI command and the MCP surface (blame is not MCP-surfaced) are unchanged.
|
|
54
|
+
|
|
55
|
+
## 0.44.0 — 2026-06-03 — One verb name across surfaces; the CLI is a contract projection ([ADRs 0058–0064](docs/architecture/decisions/))
|
|
56
|
+
|
|
57
|
+
No `textus/3` wire-format change. This release makes a verb's *name* singular across the MCP tool, the CLI command, and the use-case method; widens and hardens the agent surface; unifies `get` on read-through; and makes the CLI a derived projection of the per-verb contract so a command can no longer dispatch a differently-named verb. Several breaking (pre-1.0) renames on the MCP/CLI surfaces; no operator-visible change to CLI commands, flags, or output.
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- **BREAKING (pre-1.0): one verb name across surfaces ([ADR 0058](docs/architecture/decisions/0058-one-verb-name-across-surfaces.md)).** MCP `schema`→`schema_show`; CLI `fetch stale`→`fetch all` (the label now matches what it does); `retention_sweep`→`retain`; the redundant top-level `delete` folds into `key delete` (which gains `--if-etag`).
|
|
62
|
+
- **One rule verb, two depths ([ADR 0059](docs/architecture/decisions/0059-one-rule-verb-two-depths.md)).** MCP `rules` and CLI `rule explain` merge into `rule_explain` — lean `{fetch, guard}` by default, the full matched-blocks explanation under `--detail`/`detail: true`. `rule list` gains a use-case (`Read::RuleList`). Fixes a latent `to_h` crash on any key with a fetch rule.
|
|
63
|
+
- **Agent safety: eyes + safe defaults ([ADR 0060](docs/architecture/decisions/0060-agent-safety-graph-reads-and-default-dry-run.md)).** `deps`/`rdeps`/`where` are surfaced to MCP (the catalog grows 15→20 tools, including single-key `delete`/`mv`); the four bulk-destructive verbs (`zone_mv`, `key_mv_prefix`, `key_delete_prefix`, `migrate`) now default `dry_run: true` — an agent that omits the flag gets a Plan, not a mutation. `deps`/`rdeps` return a structured `{key, deps}`/`{key, rdeps}` on every surface.
|
|
64
|
+
- **BREAKING (pre-1.0): `build` is the verb, `publish` is the output-destination noun ([ADR 0061](docs/architecture/decisions/0061-build-publish-vocabulary.md)).** `Write::Publish`→`Write::Build`; the dispatcher verb, `RoleScope#build`, the capability, the `derived` zone-kind's required verb, the lock, and the error all read `build`. `publish` is kept only for the ADR-0052 output-destination concept (the `publish:` block, `publish_to`/`publish_tree`, `Ports::Publisher`). Ruby callers using `store.as(role).publish` must call `.build`; the MCP surface is unaffected (the verb was never surfaced).
|
|
65
|
+
- **BREAKING (pre-1.0): one `get`, read-through everywhere ([ADR 0062](docs/architecture/decisions/0062-one-get-read-through.md)).** The public `get` is read-through on every surface (fetch-on-stale per the entry's rule, degrading to a pure read when there is no fetch rule), via a single `Read::Get#call(key, fetch:)` class; the CLI's secret `get_or_fetch` upgrade and the verb itself are removed. `get --no-fetch` (MCP `{fetch: false}`) forces a pure on-disk read. The agent's `get` now returns the freshest obtainable envelope (superseding ADR 0060's pure-read stance for `get`).
|
|
66
|
+
- **The CLI is a projection of the contract ([ADR 0063](docs/architecture/decisions/0063-cli-is-a-projection-of-the-contract.md)).** A generic `CLI::Runner` (the operator mirror of `MCP::Catalog`) generates each command from its contract via two new facets — `cli` (the command path) and `cli_response` (a CLI-specific return shape, where the operator envelope differs from the agent return). Twelve verbs are now generated with no hand-class; behavioral verbs subclass `Runner::Base` and override `#invoke` only, so the verb *name* is always contract-derived. A total reconciliation spec makes name/dispatch drift — the bug behind the 0058/0059/0061 renames — unrepresentable. No operator-visible change: the set of CLI commands, flags, and output is identical.
|
|
67
|
+
- **Derive the CLI command name; guard the dispatcher key ([ADR 0064](docs/architecture/decisions/0064-derive-command-name-and-guard-dispatcher-key.md)).** Tightens ADR 0063's projection: `CLI::Runner::Base` now derives `command_name` from the contract's `cli_leaf`, so the 13 escape-hatch classes drop their literal `command_name "…"` (the verb name is authored once, in the use-case). A new guard asserts every `Dispatcher::VERBS` key equals its use-case's own `contract.verb` — the last identity link no spec checked. No operator-visible change; the set of CLI commands, flags, and output is identical.
|
|
68
|
+
|
|
12
69
|
## 0.43.2 — 2026-06-02 — Agent-legible MCP contracts ([ADR 0057](docs/architecture/decisions/0057-agent-legible-mcp-contracts.md))
|
|
13
70
|
|
|
14
71
|
No `textus/3` wire-format change. One breaking (pre-1.0) change on the MCP transport — `put`/`propose` take frontmatter under `_meta` (was `meta`) — plus per-argument descriptions on every MCP tool and a fully catalog-derived agent verb surface.
|
data/README.md
CHANGED
|
@@ -239,9 +239,9 @@ end
|
|
|
239
239
|
To keep a batch of stale intake entries current in one shot:
|
|
240
240
|
|
|
241
241
|
```sh
|
|
242
|
-
textus fetch
|
|
243
|
-
# or just fetch everything
|
|
244
|
-
textus fetch
|
|
242
|
+
textus fetch all --prefix=feeds --zone=feeds --as=automation
|
|
243
|
+
# or just fetch everything past its TTL in the feeds zone:
|
|
244
|
+
textus fetch all --zone=feeds --as=automation
|
|
245
245
|
```
|
|
246
246
|
|
|
247
247
|
See SPEC.md §5.10 for the full hook contract.
|
data/SPEC.md
CHANGED
|
@@ -456,7 +456,7 @@ A sentinel is written for each published file at `<store_root>/sentinels/<target
|
|
|
456
456
|
|
|
457
457
|
### 5.4 Intake (declared, fetched via registered intake handler)
|
|
458
458
|
|
|
459
|
-
Intake entries declare an external source by naming an **intake handler** — a registered, named function that pulls data into the entry. textus itself still makes no implicit network calls: an intake handler only runs when explicitly invoked by `textus fetch KEY --as=automation` (or by `textus fetch
|
|
459
|
+
Intake entries declare an external source by naming an **intake handler** — a registered, named function that pulls data into the entry. textus itself still makes no implicit network calls: an intake handler only runs when explicitly invoked by `textus fetch KEY --as=automation` (or by `textus fetch all`). The declaration is data only:
|
|
460
460
|
|
|
461
461
|
```yaml
|
|
462
462
|
- key: feeds.calendar.events
|
|
@@ -478,7 +478,7 @@ rules:
|
|
|
478
478
|
|
|
479
479
|
#### `on_stale:` semantics
|
|
480
480
|
|
|
481
|
-
`on_stale:` declares what happens when `
|
|
481
|
+
`on_stale:` declares what happens when `get` encounters a stale intake entry. `get` is **read-through on every surface** (CLI, Ruby, MCP): it returns the freshest obtainable envelope, fetching on a stale verdict per the entry's fetch rule and degrading to a pure on-disk read for keys with no fetch rule (ADR 0062). The value lives on the matching policy block, not on the entry. Vocabulary: `warn | sync | timed_sync`.
|
|
482
482
|
|
|
483
483
|
| Value | Behaviour |
|
|
484
484
|
|---|---|
|
|
@@ -499,7 +499,7 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
|
|
|
499
499
|
**Fetch paths.** Two are supported:
|
|
500
500
|
|
|
501
501
|
1. **In-process** — `textus fetch KEY --as=automation` 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).
|
|
502
|
-
2. **External automation** — a cron job or agent harness reads `textus list --zone=intake --stale --output=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=automation --stdin`. The CLI verb `textus fetch
|
|
502
|
+
2. **External automation** — a cron job or agent harness reads `textus list --zone=intake --stale --output=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=automation --stdin`. The CLI verb `textus fetch all [--prefix=K] [--zone=Z]` drives this loop in one shot.
|
|
503
503
|
|
|
504
504
|
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.
|
|
505
505
|
|
|
@@ -719,7 +719,7 @@ than aborting the run.
|
|
|
719
719
|
|
|
720
720
|
**Resolution.** For each key textus computes a `RuleSet { fetch, intake_handler_allowlist, guard, retention }` by walking every block whose `match` matches the key, ranked by specificity. **Per slot, the most specific block wins.** Two blocks of equal specificity that match the same key and fill the same slot is a manifest error reported by `textus doctor` (`rule_ambiguity`).
|
|
721
721
|
|
|
722
|
-
**Read surface.** `textus rule list` dumps every block. `textus rule explain KEY` shows the resolved `RuleSet` for one key
|
|
722
|
+
**Read surface.** `textus rule list` dumps every block. `textus rule explain KEY` shows the resolved `RuleSet` for one key — lean effective `{fetch, guard}` by default; `--detail` adds every matched block and the effective guard predicate names for every write transition (ADR 0059).
|
|
723
723
|
|
|
724
724
|
### 5.12 Storage formats
|
|
725
725
|
|
|
@@ -866,7 +866,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
866
866
|
|---|---|---|
|
|
867
867
|
| `list [--prefix=K] [--zone=Z]` | read | any |
|
|
868
868
|
| `where K` | read | any |
|
|
869
|
-
| `get K` | read | any |
|
|
869
|
+
| `get K [--no-fetch]` | read (read-through by default: fetch-on-stale per the entry's fetch rule, degrades to a pure read; `--no-fetch` / `{fetch:false}` for an explicit pure on-disk read) | any |
|
|
870
870
|
| `schema show K` | read | any |
|
|
871
871
|
| `freshness [--prefix=K] [--zone=Z]` | read | any |
|
|
872
872
|
| `audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]` | read | any |
|
|
@@ -881,9 +881,9 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
881
881
|
| `pulse [--since=N]` | read | any |
|
|
882
882
|
| `put K --stdin --as=R [--fetch=NAME]` | write | per zone |
|
|
883
883
|
| `propose K --stdin --as=R` | write | `propose`-holder (auto-prefixes propose_zone) |
|
|
884
|
-
| `delete K --if-etag=E --as=R` | write | per zone |
|
|
884
|
+
| `key delete K --if-etag=E --as=R` | write | per zone |
|
|
885
885
|
| `fetch KEY --as=automation` | write | `fetch`-holder (typically `automation`) |
|
|
886
|
-
| `fetch
|
|
886
|
+
| `fetch all [--prefix=K] [--zone=Z] [--as=automation]` | write | `fetch`-holder (typically `automation`) |
|
|
887
887
|
| `build [--prefix=K] [--dry-run]` | write | `build`-holder (typically `automation`) |
|
|
888
888
|
| `retain [--prefix=K] [--zone=Z] --as=ROLE` | write | per zone (role must write the matched zone) |
|
|
889
889
|
| `accept K --as=human` | write | `author`-holder (typically `human`) |
|
|
@@ -898,8 +898,8 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
898
898
|
```json
|
|
899
899
|
{
|
|
900
900
|
"agent_quickstart": {
|
|
901
|
-
"read_verbs": ["get", "list", "pulse", "
|
|
902
|
-
"write_verbs": ["
|
|
901
|
+
"read_verbs": ["get", "list", "pulse", "schema_show", "boot", "rule_explain", "where", "deps", "rdeps"],
|
|
902
|
+
"write_verbs": ["delete", "fetch", "fetch_all", "mv", "propose", "put"],
|
|
903
903
|
"writable_zones": ["proposals"],
|
|
904
904
|
"propose_zone": "proposals",
|
|
905
905
|
"latest_seq": 1842
|
|
@@ -907,7 +907,9 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
907
907
|
}
|
|
908
908
|
```
|
|
909
909
|
|
|
910
|
-
`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 (`
|
|
910
|
+
`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).
|
|
911
|
+
|
|
912
|
+
The agent's MCP write surface includes the single-key `delete` and `mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment); safety scales with blast radius — the bulk `*_prefix` ops default to a dry-run Plan, single-key `delete` executes under an optional `if_etag`, and single-key `mv` applies immediately but exposes an optional `dry_run`.
|
|
911
913
|
|
|
912
914
|
`latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
|
|
913
915
|
|
|
@@ -936,7 +938,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
936
938
|
"if_etag": "sha256:8f3c…" }
|
|
937
939
|
```
|
|
938
940
|
|
|
939
|
-
`if_etag` is optional on `put
|
|
941
|
+
`if_etag` is optional on both `put` and `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).
|
|
940
942
|
|
|
941
943
|
**`textus freshness` output shape:**
|
|
942
944
|
|
|
@@ -1109,7 +1111,7 @@ textus does not ship a built-in textus/2 → textus/3 migrator. The historical u
|
|
|
1109
1111
|
| Compute field | `projection.reduce:` | `compute.transform:` |
|
|
1110
1112
|
| `_meta` key | `reducer` | `transform` |
|
|
1111
1113
|
| CLI flag | `--format=json` (envelope) | `--output=json` |
|
|
1112
|
-
| CLI verb | `refresh-stale` | `fetch
|
|
1114
|
+
| CLI verb | `refresh-stale` | `fetch all` |
|
|
1113
1115
|
| CLI verb | `policy list/explain` | `rule list/explain` |
|
|
1114
1116
|
|
|
1115
1117
|
**Hook migration.** Legacy event names / DSL methods must be renamed to the textus/3 forms above before a hook will load; see `CHANGELOG.md` §0.11.0 for the full event-rename detail.
|
|
@@ -1124,7 +1126,7 @@ textus does not ship a built-in textus/2 → textus/3 migrator. The historical u
|
|
|
1124
1126
|
| `roles[*].kind:` (`accept_authority`/`generator`/`proposer`/`runner`) | `roles[*].can:` (subset of `propose`, `author`, `fetch`, `build`) |
|
|
1125
1127
|
| Actors `runner`, `builder` | `automation` (`can: [fetch, build]`) by default |
|
|
1126
1128
|
| `rules[*].refresh:` slot | `rules[*].fetch:` slot |
|
|
1127
|
-
| CLI `textus refresh` / `refresh stale` | `textus fetch` / `fetch
|
|
1129
|
+
| CLI `textus refresh` / `refresh stale` | `textus fetch` / `fetch all` |
|
|
1128
1130
|
| `_meta.last_refreshed_at` | `_meta.last_fetched_at` |
|
|
1129
1131
|
| Promotion predicate `:human_accept` / `:accept_authority_signed` | `:author_signed` |
|
|
1130
1132
|
| Envelope `refreshing` | `fetching` |
|
data/docs/architecture/README.md
CHANGED
|
@@ -28,6 +28,23 @@ CLI verbs: store.<verb>(..., role:)
|
|
|
28
28
|
MCP gate: textus mcp serve — same use cases, JSON-RPC.
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
The CLI is a **projection of the per-verb `Contract`** (ADR 0063), the operator
|
|
32
|
+
mirror of `MCP::Catalog`. The contract now owns the whole request lifecycle —
|
|
33
|
+
`acquire → bind → invoke → render` (ADRs 0066–0068): one `Contract::Binder.bind`
|
|
34
|
+
splits the uniform by-name `inputs` hash into the use-case's positional/keyword
|
|
35
|
+
args for every surface; per-surface `view`s shape the output (`view` for
|
|
36
|
+
MCP/Ruby, `view(:cli)` for the operator envelope); declarative `source:`/
|
|
37
|
+
`coerce:`/`cli_stdin` populate inputs from files and stdin; `around:` resources
|
|
38
|
+
wrap the single dispatch site (`RoleScope#dispatch_bound`) for stateful verbs;
|
|
39
|
+
and `cli_default:` declares a CLI default that diverges from the agent default.
|
|
40
|
+
`CLI::Runner` generates a command per `:cli` contract, dispatching `contract.verb`
|
|
41
|
+
by construction. Only verbs with genuine *behavior* — `put` (fetch
|
|
42
|
+
orchestration), `get` (UnknownKey + resolver suggestions, CLI-only), `build`
|
|
43
|
+
(actor-role resolution + BuildLock), the `fetch`/`fetch_all` workers, and the
|
|
44
|
+
`boot`/`doctor` composite reports — stay hand-authored, plus commands with no
|
|
45
|
+
dispatcher verb (`init`, `hook`, `mcp serve`, `schema diff/init`). Total
|
|
46
|
+
reconciliation specs make name/dispatch/facet drift unrepresentable.
|
|
47
|
+
|
|
31
48
|
**Application**
|
|
32
49
|
|
|
33
50
|
```
|
|
@@ -37,10 +54,10 @@ Container (single record — wired ports + manifest)
|
|
|
37
54
|
Dispatcher (static VERBS table: verb → use-case)
|
|
38
55
|
RoleScope (Store#as(role) — forwards verb calls)
|
|
39
56
|
|
|
40
|
-
read/{get,
|
|
57
|
+
read/{get,list,where,uid,schema_envelope,
|
|
41
58
|
deps,rdeps,published,stale,validate_all,boot,doctor,
|
|
42
|
-
freshness,audit,blame,
|
|
43
|
-
write/{put,delete,mv,accept,reject,
|
|
59
|
+
freshness,audit,blame,rule_explain,rule_list,pulse}.rb
|
|
60
|
+
write/{put,delete,mv,accept,reject,build,
|
|
44
61
|
materializer,intake_fetch,retention_sweep,
|
|
45
62
|
fetch_worker,fetch_orchestrator,fetch_all}
|
|
46
63
|
maintenance/{migrate,key_mv_prefix,key_delete_prefix,
|
|
@@ -171,13 +188,15 @@ The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Mani
|
|
|
171
188
|
|
|
172
189
|
## Read path (`store.get(key)`)
|
|
173
190
|
|
|
191
|
+
`Read::Get` is the single public read verb (ADR 0062). It is read-through by default: it returns the freshest obtainable envelope, fetching on a stale verdict per the entry's fetch rule, and degrading to a pure on-disk result when the key has no fetch rule. An optional `fetch: false` flag (CLI `--no-fetch`, MCP `{fetch:false}`) forces a pure on-disk read.
|
|
192
|
+
|
|
174
193
|
1. CLI verb (or MCP tool) calls `store.get(key, role:)` (or `store.as(role).get(key)`).
|
|
175
|
-
2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`.
|
|
176
|
-
3. `Read::Get#call` resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope.
|
|
177
|
-
4.
|
|
178
|
-
5.
|
|
194
|
+
2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`. The contract declares `arg :fetch, default: true`, injected by `Contract::Binder.bind` at the single verb-dispatch chokepoint (`RoleScope#dispatch_bound`) for every surface — so the public verb is always read-through unless the caller explicitly passes `fetch: false`.
|
|
195
|
+
3. `Read::Get#call(key, fetch: false)` runs the pure read sub-step inline: resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope, and annotates a freshness verdict (`stale`, `reason`, `fetching: false`). When the key has no fetch rule, the envelope is annotated fresh and returned immediately — no orchestrator is involved.
|
|
196
|
+
4. If `fetch: true` and the verdict is stale and the entry's fetch rule demands action, `Read::Get` hands off to `Write::FetchOrchestrator` (built lazily — a pure `fetch: false` call never touches the orchestrator). The orchestrator executes the fetch policy's `Action` (`sync`, `timed_sync`, `detached`, …) and returns an `Outcome`.
|
|
197
|
+
5. The outcome is mapped back to an envelope: `Fetched` → fresh envelope from the write; `Detached` → original envelope with `fetching: true`; `Failed` → original envelope with `fetch_error` set; `Skipped` → original envelope unchanged.
|
|
179
198
|
|
|
180
|
-
`
|
|
199
|
+
The pure read is `Read::Get#call(key, fetch: false)` — it is the safe default for direct in-process callers (accept/reject/publish, materializer, uid, validate_all/validator, schema/tools, hooks/context) that must never trigger a fetch. They construct `Read::Get` directly, bypassing the dispatch injection that sets `fetch: true`. The prior separate read-through path `get_or_fetch` and the separate pure class `Read::GetEntry` were both unified into the one `Read::Get` class (ADR 0062 amendment).
|
|
181
200
|
|
|
182
201
|
## Write path (`store.put(key, ...)`)
|
|
183
202
|
|
|
@@ -186,7 +205,7 @@ The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Mani
|
|
|
186
205
|
3. Delegates persistence to `Envelope::IO::Writer#put`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
|
|
187
206
|
4. Publishes `:entry_put` via `container.events` with `ctx: <Hooks::Context>`, `key:`, `envelope:`.
|
|
188
207
|
|
|
189
|
-
`Write::{Delete,Mv,Accept,Reject,
|
|
208
|
+
`Write::{Delete,Mv,Accept,Reject,Build}` follow the same shape: explicit container, the unified `Guard` for authz (built per transition via `GuardFactory`), `Envelope::IO::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
|
|
190
209
|
|
|
191
210
|
`Write::Mv` delegates the file-move + audit to `Envelope::IO::Writer#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `Envelope::IO::Writer#write` directly — no `Put` bypass.
|
|
192
211
|
|
|
@@ -92,7 +92,7 @@ External inputs land via `:resolve_intake` hooks, not shell commands. Each intak
|
|
|
92
92
|
|
|
93
93
|
```sh
|
|
94
94
|
textus fetch feeds.notion.roadmap --as=automation
|
|
95
|
-
textus fetch
|
|
95
|
+
textus fetch all --zone=feeds --as=automation # everything past its TTL
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
Freshness budgets live in the top-level `rules:` block, matched by glob:
|
|
@@ -103,24 +103,23 @@ rules:
|
|
|
103
103
|
fetch: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
A typical scheduled-fetch integration shells the `fetch
|
|
106
|
+
A typical scheduled-fetch integration shells the `fetch all` sweep itself:
|
|
107
107
|
|
|
108
108
|
```sh
|
|
109
|
-
textus fetch
|
|
109
|
+
textus fetch all --zone=feeds --as=automation # in cron / CI
|
|
110
110
|
```
|
|
111
111
|
|
|
112
112
|
See [`./zones.md` §6](zones.md) for the full intake contract and [`../how-to/writing-hooks.md`](../how-to/writing-hooks.md) for writing custom handlers.
|
|
113
113
|
|
|
114
114
|
### Read vs. fetch
|
|
115
115
|
|
|
116
|
-
There
|
|
116
|
+
There is one public read operation (ADR 0062):
|
|
117
117
|
|
|
118
|
-
| Operation |
|
|
119
|
-
|
|
120
|
-
| `ops.get` |
|
|
121
|
-
| `ops.get_or_fetch` | Yes — composes `get` with the orchestrator per the rule's `on_stale` policy | interactive reads (`textus get`, dashboards) where the caller wants the freshest envelope obtainable |
|
|
118
|
+
| Operation | Behaviour | Use for |
|
|
119
|
+
|-----------|-----------|---------|
|
|
120
|
+
| `ops.get` | Read-through by default — fetches on stale per the entry's fetch rule; degrades to a pure on-disk read when the key has no fetch rule. Pass `fetch: false` (CLI `--no-fetch`, MCP `{fetch:false}`) for an explicit pure read | all callers, including interactive reads, dashboards, and scripts that want the freshest obtainable envelope |
|
|
122
121
|
|
|
123
|
-
Build
|
|
122
|
+
Build pipelines and other internal callers that must never trigger a fetch (materializer, projection, schema tooling, accept/reject/publish, uid, validator) construct `Read::Get` directly with the method default `fetch: false` — a pure, orchestrator-free read. They bypass the verb-dispatch injection that sets `fetch: true`, so they always get pure reads without any extra argument.
|
|
124
123
|
|
|
125
124
|
## Body content
|
|
126
125
|
|
data/lib/textus/boot.rb
CHANGED
|
@@ -81,12 +81,11 @@ module Textus
|
|
|
81
81
|
{ "name" => "list" },
|
|
82
82
|
{ "name" => "get" },
|
|
83
83
|
{ "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
|
|
84
|
-
{ "name" => "schema" },
|
|
84
|
+
{ "name" => "schema", "summary" => "schema operations: 'schema show KEY', 'schema diff', 'schema init', 'schema migrate'" },
|
|
85
85
|
{ "name" => "put" },
|
|
86
86
|
{ "name" => "propose" },
|
|
87
87
|
{ "name" => "accept", "summary" => "apply a queued proposal to its target zone; requires the author capability" },
|
|
88
|
-
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
|
|
89
|
-
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
88
|
+
{ "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
|
|
90
89
|
{ "name" => "build", "summary" => "materialize derived entries; publish_to and publish_tree fan out copies" },
|
|
91
90
|
{ "name" => "fetch" },
|
|
92
91
|
{ "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
|
|
@@ -131,7 +130,7 @@ module Textus
|
|
|
131
130
|
# agent's real read and write surface, named as verbs the agent calls —
|
|
132
131
|
# not CLI strings. read_verbs can neither advertise a verb the agent
|
|
133
132
|
# cannot call (audit/freshness/doctor are CLI-only) nor omit one it can
|
|
134
|
-
# (
|
|
133
|
+
# (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
|
|
135
134
|
# framing (role is connection-resolved over MCP; there is no stdin).
|
|
136
135
|
# writable_zones / propose_zone below carry the agent's write authority.
|
|
137
136
|
"read_verbs" => Textus::MCP::Catalog.read_verbs,
|
data/lib/textus/cli/group.rb
CHANGED
|
@@ -6,6 +6,7 @@ module Textus
|
|
|
6
6
|
# `parent_group` is this group counts as a subcommand. Sorted
|
|
7
7
|
# alphabetically by command_name for stable help output.
|
|
8
8
|
def subcommands
|
|
9
|
+
Textus::CLI::Runner.install!
|
|
9
10
|
Verb.descendants
|
|
10
11
|
.select { |k| k.parent_group == self && k.command_name }
|
|
11
12
|
.sort_by(&:command_name)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
# Generates CLI::Verb (and CLI::Group) subclasses from per-verb contracts,
|
|
4
|
+
# so the CLI surface is a projection of the contract — the operator-facing
|
|
5
|
+
# mirror of MCP::Catalog (ADR 0063).
|
|
6
|
+
module Runner
|
|
7
|
+
# Subclassable base for contract-projected verbs. Carries the verb's
|
|
8
|
+
# contract (class attr `spec`) and the generic dispatch, exposing one
|
|
9
|
+
# overridable seam, #invoke, that defaults to the generic projection.
|
|
10
|
+
# Escape-hatch verbs subclass this and override #invoke to add behavior
|
|
11
|
+
# (suggestions, --stdin, BuildLock, multi-dispatch) WITHOUT restating the
|
|
12
|
+
# verb name — `spec.verb` remains the single source of dispatch.
|
|
13
|
+
class Base < Verb
|
|
14
|
+
class << self
|
|
15
|
+
attr_accessor :spec
|
|
16
|
+
|
|
17
|
+
# ADR 0064: derive the CLI command name from the contract's cli_leaf
|
|
18
|
+
# when not set explicitly, so an escape-hatch class never restates its
|
|
19
|
+
# own name. The reconciliation spec proves command_name == cli_leaf for
|
|
20
|
+
# every such class, so this is an equivalence, not a behavior change.
|
|
21
|
+
def command_name(name = nil)
|
|
22
|
+
return super if name
|
|
23
|
+
|
|
24
|
+
super() || spec&.cli_leaf
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def spec = self.class.spec
|
|
29
|
+
|
|
30
|
+
def call(store)
|
|
31
|
+
invoke(store)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Default: pure contract projection. Override in subclasses for behavior.
|
|
35
|
+
def invoke(store)
|
|
36
|
+
Runner.dispatch(self, store, spec)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def flag_values(s = spec)
|
|
40
|
+
s.args.reject(&:positional).each_with_object({}) do |a, h|
|
|
41
|
+
raw = respond_to?(a.name) ? public_send(a.name) : nil
|
|
42
|
+
next if raw.nil?
|
|
43
|
+
|
|
44
|
+
h[a.name] = Runner.coerce(a, raw)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
# Normalize parsed CLI input into the uniform by-name inputs hash and
|
|
52
|
+
# dispatch through RoleScope's single bind+invoke site. A missing required
|
|
53
|
+
# arg becomes a UsageError phrased in the operator's command path (parity
|
|
54
|
+
# with the hand-written verbs).
|
|
55
|
+
def dispatch(verb_instance, store, spec)
|
|
56
|
+
inputs = Textus::Contract::Binder.inputs_from_ordered(
|
|
57
|
+
spec, verb_instance.positional, verb_instance.flag_values(spec)
|
|
58
|
+
)
|
|
59
|
+
inputs = inputs.merge(Textus::Contract::Sources.from_stdin(spec, verb_instance.stdin)) if spec.cli_stdin
|
|
60
|
+
inputs = Textus::Contract::Sources.acquire(spec, inputs)
|
|
61
|
+
inputs = apply_cli_defaults(spec, inputs)
|
|
62
|
+
scope = verb_instance.session_for(store)
|
|
63
|
+
begin
|
|
64
|
+
result = scope.dispatch_bound(spec.verb, inputs)
|
|
65
|
+
rescue Textus::Contract::MissingArgs => e
|
|
66
|
+
raise UsageError.new("#{spec.cli_path} requires #{e.missing.first.wire}")
|
|
67
|
+
end
|
|
68
|
+
verb_instance.emit(shape(spec, result, inputs))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Fill CLI-specific defaults (cli_default:) for args the operator did not
|
|
72
|
+
# pass, where the CLI default diverges from the contract default the agent
|
|
73
|
+
# surfaces use — e.g. migrate/zone_mv apply by default on the CLI but plan
|
|
74
|
+
# by default for agents (ADR 0068). The divergence is legible in the
|
|
75
|
+
# contract, not hidden in a hand class.
|
|
76
|
+
def apply_cli_defaults(spec, inputs)
|
|
77
|
+
spec.args.each_with_object(inputs.dup) do |a, h|
|
|
78
|
+
next if a.cli_default == :__unset || h.key?(a.name)
|
|
79
|
+
|
|
80
|
+
h[a.name] = a.cli_default
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Shape the use-case result for the CLI wire via the verb's :cli view
|
|
85
|
+
# (falling back to the default view). The view is called uniformly as
|
|
86
|
+
# (result, inputs); an inputs-aware view echoes an input such as the key
|
|
87
|
+
# (ADR 0067).
|
|
88
|
+
def shape(spec, result, inputs)
|
|
89
|
+
Textus::Contract::View.render(spec, :cli, result, inputs)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The default the CLI flag is generated against — `cli_default:` when the
|
|
93
|
+
# operator-facing default diverges from the contract default the agent
|
|
94
|
+
# surfaces use, else the contract `default`. This drives boolean flag
|
|
95
|
+
# polarity so a verb that applies-by-default on the CLI but plans-by-default
|
|
96
|
+
# for agents (migrate, zone_mv) gets a `--dry-run` flag, not `--no-dry-run`.
|
|
97
|
+
def effective_default(arg)
|
|
98
|
+
arg.cli_default == :__unset ? arg.default : arg.cli_default
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def flagspec_for(arg)
|
|
102
|
+
wire = arg.wire.to_s.tr("_", "-")
|
|
103
|
+
if arg.type == :boolean
|
|
104
|
+
effective_default(arg) == true ? "--no-#{wire}" : "--#{wire}"
|
|
105
|
+
else
|
|
106
|
+
"--#{wire}=VALUE"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# NB: compare arg.type by equality, not `case`/`===` — `Integer === arg.type`
|
|
111
|
+
# is false when arg.type is the Integer *class* (it tests instance-of), so a
|
|
112
|
+
# `when Integer` branch would silently never coerce.
|
|
113
|
+
def coerce(arg, raw)
|
|
114
|
+
return effective_default(arg) != true if arg.type == :boolean
|
|
115
|
+
return Integer(raw) if arg.type == Integer
|
|
116
|
+
|
|
117
|
+
raw
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ensure_group(name)
|
|
121
|
+
const = name.split("_").map(&:capitalize).join
|
|
122
|
+
return Group.const_get(const, false) if Group.const_defined?(const, false)
|
|
123
|
+
|
|
124
|
+
g = Class.new(Group) { command_name name }
|
|
125
|
+
Group.const_set(const, g)
|
|
126
|
+
g
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Contract verbs whose CLI behavior is a genuine `< Runner::Base` override
|
|
130
|
+
# — behavior the generic projection cannot express (ADR 0068/0069):
|
|
131
|
+
# get — raises UnknownKey with resolver suggestions (a CLI-only
|
|
132
|
+
# affordance; the agent surface deliberately returns nil)
|
|
133
|
+
# put — IntakeFetch read-through orchestration on --fetch
|
|
134
|
+
# build — auto-resolves the build-capability actor role (not --as) and
|
|
135
|
+
# serializes under BuildLock; the role resolution is policy, not
|
|
136
|
+
# a projection (around: covers only the lock)
|
|
137
|
+
BEHAVIORAL_HATCHES = %i[get put build].freeze
|
|
138
|
+
|
|
139
|
+
# Contract verbs whose CLI is a plain `< Verb` command, not a projection at
|
|
140
|
+
# all — worker verbs and composite reports assembled outside the contract:
|
|
141
|
+
# fetch, fetch_all — background intake workers (not request/response)
|
|
142
|
+
# boot, doctor — composite reports
|
|
143
|
+
NON_PROJECTED_CLI = %i[fetch fetch_all boot doctor].freeze
|
|
144
|
+
|
|
145
|
+
# The installer skips generation for either category.
|
|
146
|
+
HAND_AUTHORED_VERBS = (BEHAVIORAL_HATCHES + NON_PROJECTED_CLI).freeze
|
|
147
|
+
|
|
148
|
+
def hand_authored?(verb) = HAND_AUTHORED_VERBS.include?(verb)
|
|
149
|
+
|
|
150
|
+
def install!
|
|
151
|
+
@installed ||= {}
|
|
152
|
+
Textus::Dispatcher::VERBS.each_value do |klass|
|
|
153
|
+
next unless klass.respond_to?(:contract?) && klass.contract?
|
|
154
|
+
|
|
155
|
+
spec = klass.contract
|
|
156
|
+
next unless spec.cli?
|
|
157
|
+
next if hand_authored?(spec.verb)
|
|
158
|
+
next if @installed[spec.verb]
|
|
159
|
+
|
|
160
|
+
install_for(spec)
|
|
161
|
+
@installed[spec.verb] = true
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def install_for(spec)
|
|
166
|
+
group = spec.cli_group ? ensure_group(spec.cli_group) : nil
|
|
167
|
+
leaf = spec.cli_leaf
|
|
168
|
+
non_positional = spec.args.reject(&:positional)
|
|
169
|
+
|
|
170
|
+
klass = Class.new(Base)
|
|
171
|
+
klass.spec = spec
|
|
172
|
+
klass.command_name leaf
|
|
173
|
+
klass.parent_group group if group
|
|
174
|
+
klass.option :as_flag, "--as=ROLE"
|
|
175
|
+
klass.option :use_stdin, "--stdin" if spec.cli_stdin
|
|
176
|
+
non_positional.each { |a| klass.option a.name, Runner.flagspec_for(a) }
|
|
177
|
+
|
|
178
|
+
# Anchor the anonymous class to a constant so descendants discovery is
|
|
179
|
+
# stable. Name it after the verb under a Generated namespace.
|
|
180
|
+
const_name = spec.verb.to_s.split("_").map(&:capitalize).join
|
|
181
|
+
gen = "Gen#{const_name}"
|
|
182
|
+
Verb.const_set(gen, klass) unless Verb.const_defined?(gen, false)
|
|
183
|
+
klass
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
|
-
class Build <
|
|
5
|
-
|
|
4
|
+
class Build < Runner::Base
|
|
5
|
+
self.spec = Textus::Write::Build.contract
|
|
6
6
|
|
|
7
7
|
option :prefix, "--prefix=K"
|
|
8
8
|
|
|
9
|
-
def
|
|
9
|
+
def invoke(store)
|
|
10
10
|
role = store.manifest.policy.actor_for("build") or
|
|
11
11
|
raise UsageError.new(
|
|
12
12
|
"no role holds the 'build' capability",
|
|
@@ -14,7 +14,7 @@ module Textus
|
|
|
14
14
|
)
|
|
15
15
|
Textus::Ports::BuildLock.with(root: store.root) do
|
|
16
16
|
ops = store.as(role)
|
|
17
|
-
result = ops.
|
|
17
|
+
result = ops.build(prefix: prefix)
|
|
18
18
|
emit(result)
|
|
19
19
|
end
|
|
20
20
|
end
|
data/lib/textus/cli/verb/get.rb
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
|
-
class Get <
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
class Get < Runner::Base
|
|
5
|
+
self.spec = Textus::Read::Get.contract
|
|
7
6
|
option :as_flag, "--as=ROLE"
|
|
7
|
+
option :no_fetch, "--no-fetch"
|
|
8
8
|
|
|
9
|
-
def
|
|
9
|
+
def invoke(store)
|
|
10
10
|
key = positional.shift or raise UsageError.new("get requires a key")
|
|
11
|
-
|
|
11
|
+
kw = no_fetch.nil? ? {} : { fetch: false }
|
|
12
|
+
result = session_for(store).get(key, **kw)
|
|
12
13
|
raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
|
|
13
14
|
|
|
14
15
|
emit(result.to_h_for_wire)
|