textus 0.43.1 → 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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +3 -3
  4. data/SPEC.md +15 -13
  5. data/docs/architecture/README.md +28 -9
  6. data/docs/reference/conventions.md +8 -9
  7. data/lib/textus/boot.rb +10 -7
  8. data/lib/textus/cli/group/fetch.rb +2 -2
  9. data/lib/textus/cli/group.rb +1 -0
  10. data/lib/textus/cli/runner.rb +187 -0
  11. data/lib/textus/cli/verb/build.rb +4 -4
  12. data/lib/textus/cli/verb/{fetch_stale.rb → fetch_all.rb} +2 -2
  13. data/lib/textus/cli/verb/get.rb +6 -5
  14. data/lib/textus/cli/verb/put.rb +3 -3
  15. data/lib/textus/cli/verb.rb +3 -0
  16. data/lib/textus/cli.rb +8 -2
  17. data/lib/textus/contract/around.rb +29 -0
  18. data/lib/textus/contract/binder.rb +88 -0
  19. data/lib/textus/contract/resources/cursor.rb +26 -0
  20. data/lib/textus/contract/sources.rb +39 -0
  21. data/lib/textus/contract/view.rb +15 -0
  22. data/lib/textus/contract.rb +78 -10
  23. data/lib/textus/dispatcher.rb +5 -6
  24. data/lib/textus/hooks/context.rb +24 -2
  25. data/lib/textus/maintenance/key_delete_prefix.rb +6 -4
  26. data/lib/textus/maintenance/key_mv_prefix.rb +7 -5
  27. data/lib/textus/maintenance/migrate.rb +12 -8
  28. data/lib/textus/maintenance/rule_lint.rb +4 -2
  29. data/lib/textus/maintenance/zone_mv.rb +7 -5
  30. data/lib/textus/manifest/entry/base.rb +1 -1
  31. data/lib/textus/mcp/catalog.rb +17 -33
  32. data/lib/textus/projection.rb +2 -2
  33. data/lib/textus/read/audit.rb +19 -0
  34. data/lib/textus/read/blame.rb +11 -1
  35. data/lib/textus/read/deps.rb +15 -1
  36. data/lib/textus/read/doctor.rb +8 -0
  37. data/lib/textus/read/freshness.rb +10 -0
  38. data/lib/textus/read/get.rb +88 -22
  39. data/lib/textus/read/list.rb +3 -2
  40. data/lib/textus/read/published.rb +7 -0
  41. data/lib/textus/read/pulse.rb +1 -0
  42. data/lib/textus/read/rdeps.rb +14 -0
  43. data/lib/textus/read/rule_explain.rb +84 -0
  44. data/lib/textus/read/rule_list.rb +39 -0
  45. data/lib/textus/read/schema_envelope.rb +5 -3
  46. data/lib/textus/read/uid.rb +9 -0
  47. data/lib/textus/read/where.rb +8 -0
  48. data/lib/textus/role_scope.rb +34 -6
  49. data/lib/textus/schema/tools.rb +12 -3
  50. data/lib/textus/version.rb +1 -1
  51. data/lib/textus/write/accept.rb +8 -0
  52. data/lib/textus/write/{publish.rb → build.rb} +16 -7
  53. data/lib/textus/write/delete.rb +13 -0
  54. data/lib/textus/write/fetch_all.rb +3 -2
  55. data/lib/textus/write/fetch_orchestrator.rb +1 -1
  56. data/lib/textus/write/fetch_worker.rb +3 -2
  57. data/lib/textus/write/mv.rb +16 -0
  58. data/lib/textus/write/propose.rb +12 -4
  59. data/lib/textus/write/put.rb +11 -6
  60. data/lib/textus/write/reject.rb +8 -0
  61. data/lib/textus/write/retention_sweep.rb +9 -0
  62. metadata +11 -29
  63. data/lib/textus/cli/verb/accept.rb +0 -16
  64. data/lib/textus/cli/verb/audit.rb +0 -34
  65. data/lib/textus/cli/verb/blame.rb +0 -17
  66. data/lib/textus/cli/verb/delete.rb +0 -17
  67. data/lib/textus/cli/verb/deps.rb +0 -14
  68. data/lib/textus/cli/verb/freshness.rb +0 -17
  69. data/lib/textus/cli/verb/key_delete.rb +0 -24
  70. data/lib/textus/cli/verb/list.rb +0 -16
  71. data/lib/textus/cli/verb/migrate.rb +0 -18
  72. data/lib/textus/cli/verb/mv.rb +0 -27
  73. data/lib/textus/cli/verb/propose.rb +0 -28
  74. data/lib/textus/cli/verb/published.rb +0 -13
  75. data/lib/textus/cli/verb/pulse.rb +0 -26
  76. data/lib/textus/cli/verb/rdeps.rb +0 -14
  77. data/lib/textus/cli/verb/reject.rb +0 -16
  78. data/lib/textus/cli/verb/retain.rb +0 -19
  79. data/lib/textus/cli/verb/rule_explain.rb +0 -16
  80. data/lib/textus/cli/verb/rule_lint.rb +0 -18
  81. data/lib/textus/cli/verb/rule_list.rb +0 -29
  82. data/lib/textus/cli/verb/schema.rb +0 -15
  83. data/lib/textus/cli/verb/uid.rb +0 -15
  84. data/lib/textus/cli/verb/where.rb +0 -14
  85. data/lib/textus/cli/verb/zone_mv.rb +0 -19
  86. data/lib/textus/read/get_or_fetch.rb +0 -69
  87. data/lib/textus/read/policy_explain.rb +0 -46
  88. data/lib/textus/read/rules.rb +0 -24
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c26661afb6c59f813ccdb737e56666be242a14e3315a100348dd8514a41e78a
4
- data.tar.gz: 26ff3e7e8cd94a545cdcabe964c6e8c7311fe0179a24a13b0ab298eab7f2bf34
3
+ metadata.gz: dfb215839c07335a72f604bbcb830d6027a118f500be30243a592cc9c5b07cf0
4
+ data.tar.gz: dd0103470acbdfb747077bc6d0fafbc08700edfe5a24d09416723d47517871df
5
5
  SHA512:
6
- metadata.gz: db6b8047ba7d7c79ad78a653944709d7cc7cfd5c22a4baae116e97c8d6fea48c409788c835e1c1f83c7684a58da089b08a0525a44791de92875d1d2bb0c26a76
7
- data.tar.gz: 26e9d74398110f4d34dd59c837901170e5d80a847268f5edee45010f4793c51e1f47737d5d3c85b0098fa06b1334e00bf1038330a12a1061c9957d045e07638c
6
+ metadata.gz: 310065adbf501efcd2b793b896770a9cc7e5a38e41c23852c12b18dfca5cc93cea7af1545bac5e306a667aeac959995c9cf08468a3d86869facec42d11c1501f
7
+ data.tar.gz: 938e0ed014c543d2cba7190ab6de0440a36670268b4207af19444934fb28560cda382452c0cb1db26e4c31bfe78b89031c52fbdb0196fc7d67eb360013d58d5f
data/CHANGELOG.md CHANGED
@@ -9,6 +9,76 @@ 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
+
69
+ ## 0.43.2 — 2026-06-02 — Agent-legible MCP contracts ([ADR 0057](docs/architecture/decisions/0057-agent-legible-mcp-contracts.md))
70
+
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.
72
+
73
+ ### Changed
74
+
75
+ - **BREAKING (pre-1.0): MCP `put`/`propose` take frontmatter under `_meta`, not `meta` ([ADR 0057](docs/architecture/decisions/0057-agent-legible-mcp-contracts.md)).** Every *read* returns the envelope under `_meta` (`get`, SPEC §8), and the CLI `--stdin` envelope already speaks `_meta` — only the MCP `put`/`propose` argument diverged as `meta`, breaking the natural read → edit → write round-trip. The contract gains a `wire_name` primitive so the wire speaks `_meta` while the use-case keeps its `meta:` kwarg (the ADR 0039 signature guard still reconciles). An MCP client that hardcoded the `meta` argument renames one key. The Ruby API (`store.put(meta:)`) and the CLI `--stdin` envelope are unchanged.
76
+ - **`boot.agent_quickstart.write_verbs` derives from the MCP catalog ([ADR 0057](docs/architecture/decisions/0057-agent-legible-mcp-contracts.md)).** It now returns bare verb names (`put propose fetch fetch_all`) like `read_verbs`, instead of the CLI string `"put KEY --as=agent --stdin"` (`--as`/`--stdin` are meaningless on an MCP connection — role is connection-resolved, ADR 0040). Closes the de-CLI follow-up named in ADR 0056; a symmetric guard spec fails the build if a CLI string creeps back.
77
+
78
+ ### Added
79
+
80
+ - **Per-argument descriptions on every MCP tool ([ADR 0057](docs/architecture/decisions/0057-agent-legible-mcp-contracts.md)).** All MCP-surfaced verb arguments now carry a one-line `description:` in their `tools/list` `inputSchema` (was 2 of ~30) — including `put`'s `body`/`content` mutual-exclusivity and the maintenance `dry_run` "default false applies immediately" gotcha. They ship once in `tools/list` at no per-call cost, so an agent can form a valid call from the schema alone.
81
+
12
82
  ## 0.43.1 — 2026-06-02 — `boot` agent surface derives from the MCP catalog ([ADR 0056](docs/architecture/decisions/0056-boot-quickstart-speaks-the-mcp-catalog.md))
13
83
 
14
84
  No `textus/3` wire-format change — `boot`'s agent-orientation fields are corrected to match the verbs an MCP agent can actually call.
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 stale --prefix=feeds --zone=feeds --as=automation
243
- # or just fetch everything stale in the feeds zone:
244
- textus fetch stale --zone=feeds --as=automation
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 stale`). The declaration is data only:
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 `textus get` (or any read path that annotates freshness) encounters a stale intake entry. The value lives on the matching policy block, not on the entry. Vocabulary: `warn | sync | timed_sync`.
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 stale [--prefix=K] [--zone=Z]` drives this loop in one shot.
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 plus the effective guard predicate names for every write transition.
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 stale [--prefix=K] [--zone=Z] [--as=automation]` | write | `fetch`-holder (typically `automation`) |
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", "schema", "boot", "rules"],
902
- "write_verbs": ["put KEY --as=agent --stdin"],
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 (`schema` for an entry's field shape, `rules` for its freshness/guard policy) and never the CLI-only `audit`/`freshness`/`doctor` (ADR 0056). An agent learns an entry's `_meta` shape by calling the `schema` verb before a `put`/`propose`, not by shelling out to a CLI.
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`, required on `delete`. When provided, the write fails with `etag_mismatch` if the on-disk file's etag differs. When omitted on `put`, the write is unconditional (last-writer-wins).
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 stale` |
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 stale` |
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` |
@@ -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,get_or_fetch,list,where,uid,schema_envelope,
57
+ read/{get,list,where,uid,schema_envelope,
41
58
  deps,rdeps,published,stale,validate_all,boot,doctor,
42
- freshness,audit,blame,policy_explain,pulse}.rb
43
- write/{put,delete,mv,accept,reject,publish,
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. Looks up the fetch policy via `container.manifest.rules.for(key)`. If absent, returns the envelope annotated fresh.
178
- 5. Otherwise `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`; the envelope is annotated with `stale`, `reason`, `fetching: false`.
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
- `store.get_or_fetch(key)` composes `Read::Get` with `Write::FetchOrchestrator` to optionally fetch on stale.
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,Publish}` 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.
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 stale --zone=feeds --as=automation # everything past its TTL
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 stale` sweep itself:
106
+ A typical scheduled-fetch integration shells the `fetch all` sweep itself:
107
107
 
108
108
  ```sh
109
- textus fetch stale --zone=feeds --as=automation # in cron / CI
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 are two read operations, and the difference matters in custom code:
116
+ There is one public read operation (ADR 0062):
117
117
 
118
- | Operation | Triggers fetch? | Use for |
119
- |-----------|-----------------|---------|
120
- | `ops.get` | Nopure read of on-disk envelope + freshness verdict | build / materialization paths, schema tooling, any context where a side-effecting read would surprise the caller |
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 always uses the pure path; injecting fetch into materialization caused the cascading-staleness incident behind issue #59. Pick `get_or_fetch` only when you genuinely want side effects on read.
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)" },
@@ -127,11 +126,15 @@ module Textus
127
126
  propose_zone = manifest.policy.propose_zone_for(agent_role)
128
127
 
129
128
  {
130
- # Derived from the MCP catalog (ADR 0056): the agent's real read surface,
131
- # so the quickstart can neither advertise a verb the agent cannot call
132
- # (audit/freshness/doctor are CLI-only) nor omit one it can (schema/rules).
129
+ # Both verb lists derive from the MCP catalog (ADR 0056, ADR 0057): the
130
+ # agent's real read and write surface, named as verbs the agent calls
131
+ # not CLI strings. read_verbs can neither advertise a verb the agent
132
+ # cannot call (audit/freshness/doctor are CLI-only) nor omit one it can
133
+ # (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
134
+ # framing (role is connection-resolved over MCP; there is no stdin).
135
+ # writable_zones / propose_zone below carry the agent's write authority.
133
136
  "read_verbs" => Textus::MCP::Catalog.read_verbs,
134
- "write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
137
+ "write_verbs" => agent_role ? Textus::MCP::Catalog.write_verbs : [],
135
138
  "writable_zones" => writable_zones,
136
139
  "propose_zone" => propose_zone,
137
140
  "latest_seq" => audit_log.latest_seq,
@@ -5,9 +5,9 @@ module Textus
5
5
  command_name "fetch"
6
6
 
7
7
  def parse(argv)
8
- if argv.first == "stale"
8
+ if argv.first == "all"
9
9
  argv.shift
10
- @sub_klass = Verb::FetchStale
10
+ @sub_klass = Verb::FetchAll
11
11
  else
12
12
  @sub_klass = Verb::Fetch
13
13
  end
@@ -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 < Verb
5
- command_name "build"
4
+ class Build < Runner::Base
5
+ self.spec = Textus::Write::Build.contract
6
6
 
7
7
  option :prefix, "--prefix=K"
8
8
 
9
- def call(store)
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.publish(prefix: prefix)
17
+ result = ops.build(prefix: prefix)
18
18
  emit(result)
19
19
  end
20
20
  end
@@ -1,8 +1,8 @@
1
1
  module Textus
2
2
  class CLI
3
3
  class Verb
4
- class FetchStale < Verb
5
- command_name "stale"
4
+ class FetchAll < Verb
5
+ command_name "all"
6
6
  parent_group Group::Fetch
7
7
 
8
8
  option :prefix, "--prefix=KEY"