textus 0.43.2 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +56 -29
- data/SPEC.md +24 -22
- data/docs/architecture/README.md +32 -32
- data/docs/reference/conventions.md +8 -9
- data/lib/textus/boot.rb +4 -4
- data/lib/textus/builder/pipeline.rb +11 -42
- data/lib/textus/builder/renderer/markdown.rb +4 -8
- 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 +37 -3
- data/lib/textus/container.rb +3 -15
- 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 +6 -6
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +1 -1
- data/lib/textus/domain/policy/predicates/fresh_within.rb +6 -5
- data/lib/textus/envelope/io/writer.rb +34 -0
- data/lib/textus/hooks/context.rb +24 -2
- data/lib/textus/layout.rb +8 -0
- data/lib/textus/maintenance/key_delete_prefix.rb +8 -5
- data/lib/textus/maintenance/key_mv_prefix.rb +18 -6
- data/lib/textus/maintenance/migrate.rb +14 -10
- data/lib/textus/maintenance/rule_lint.rb +5 -4
- data/lib/textus/maintenance/zone_mv.rb +9 -6
- data/lib/textus/manifest/entry/base.rb +1 -1
- data/lib/textus/mcp/catalog.rb +6 -33
- data/lib/textus/ports/publisher.rb +3 -2
- data/lib/textus/ports/sentinel_store.rb +8 -7
- data/lib/textus/projection.rb +6 -5
- data/lib/textus/read/audit.rb +19 -0
- data/lib/textus/read/blame.rb +11 -1
- data/lib/textus/read/boot.rb +1 -1
- data/lib/textus/read/capabilities.rb +70 -0
- 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 +87 -22
- data/lib/textus/read/list.rb +2 -1
- data/lib/textus/read/published.rb +7 -0
- data/lib/textus/read/pulse.rb +2 -1
- 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/store.rb +47 -24
- 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 +2 -1
- data/lib/textus/write/fetch_orchestrator.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +2 -2
- data/lib/textus/write/mv.rb +16 -0
- data/lib/textus/write/propose.rb +8 -3
- data/lib/textus/write/put.rb +3 -3
- data/lib/textus/write/reject.rb +8 -0
- data/lib/textus/write/retention_sweep.rb +9 -0
- metadata +12 -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: cd3d64e647d112827056119d45576eda3b7f6ba95446f7a08522d674fb496d2c
|
|
4
|
+
data.tar.gz: b8c7c4d86eccd1469bdd024732dc0321fb64d837f59451efe1280fd7ae8d96e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 10096cbffbd633d18a915744ddc4e70b7324ad955525a76fc9fb4b95b32ab8b0781d566e10d1fc6695dd5a795b4721926859030e4c86e661bdef1728304b62aa
|
|
7
|
+
data.tar.gz: d393eee10185c057a7fe727dbb9abb572761e12bcec4b5253cda9a5367d5cd3684850e87432079cd71296f2ff917bd4e1a3e1be5b6484cc7be5ff3c33b4611a0
|
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.46.0 — 2026-06-03 — Container is the single source of truth
|
|
13
|
+
|
|
14
|
+
No `textus/3` wire-format change. Internal refactor of the composition root. The 7-field capability set (`manifest, file_store, schemas, root, audit_log, events, rpc`) was previously spelled out four times — `Container`'s `Data.define`, `Store`'s ivar assignments, `Store`'s `attr_reader`s, and `Container.from_store`. It now lives in exactly one place.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **`Store` builds its `Container` once and derives its readers from it.** `Store#initialize` constructs the `Container` directly (`build_container`); the public accessors (`store.manifest`, `store.root`, …) are generated from `Container.members`, so adding a capability to the `Data.define` auto-exposes it on `Store`. Hook wiring is extracted into `bootstrap_hooks`.
|
|
19
|
+
- **`Store.discover` decomposed.** The upward directory walk is extracted into `ascend_for_store`, and the duplicated `.textus`/`manifest.yaml` existence check into a shared `store_dir?` predicate.
|
|
20
|
+
|
|
21
|
+
### Breaking (pre-1.0)
|
|
22
|
+
|
|
23
|
+
- **`Container.from_store` is removed.** It built a fresh `Container` by copying the seven accessors off a `Store`. Use `store.container` instead (built once, memoized). Specs that swapped the event bus post-construction via `store.instance_variable_set(:@events, …)` now inject explicitly through the immutable `Container`'s `#with` (e.g. `container.with(events: probe)`). Retires the `from_store` idiom described in [ADR 0016](docs/architecture/decisions/0016-application-ports-value.md) and [ADR 0020](docs/architecture/decisions/0020-capability-records.md).
|
|
24
|
+
|
|
25
|
+
## 0.45.1 — 2026-06-03 — Single-path lifecycle: kill the last dual-paths ([ADR 0069](docs/architecture/decisions/0069-single-path-lifecycle.md))
|
|
26
|
+
|
|
27
|
+
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.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **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 }`.
|
|
32
|
+
- **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.
|
|
33
|
+
- **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).
|
|
34
|
+
- **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.
|
|
35
|
+
|
|
36
|
+
### Breaking (pre-1.0)
|
|
37
|
+
|
|
38
|
+
- **`Binder.bind` / `RoleScope#dispatch_bound` drop the `validate:` keyword.** Callers passing it must remove it; validation is always on.
|
|
39
|
+
- **`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.
|
|
40
|
+
- **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}`.
|
|
41
|
+
|
|
42
|
+
## 0.45.0 — 2026-06-03 — The contract owns the request lifecycle ([ADRs 0066–0068](docs/architecture/decisions/))
|
|
43
|
+
|
|
44
|
+
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`).
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **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.
|
|
49
|
+
- **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.
|
|
50
|
+
- **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.
|
|
51
|
+
- **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.
|
|
52
|
+
- **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.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **`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.
|
|
57
|
+
- **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.
|
|
58
|
+
|
|
59
|
+
## 0.44.1 — 2026-06-03 — Finish the CLI contract projection ([ADR 0065](docs/architecture/decisions/0065-finish-cli-response-shrink-escape-hatches.md))
|
|
60
|
+
|
|
61
|
+
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.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- **`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.
|
|
66
|
+
- **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.
|
|
67
|
+
|
|
68
|
+
## 0.44.0 — 2026-06-03 — One verb name across surfaces; the CLI is a contract projection ([ADRs 0058–0064](docs/architecture/decisions/))
|
|
69
|
+
|
|
70
|
+
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.
|
|
71
|
+
|
|
72
|
+
### Changed
|
|
73
|
+
|
|
74
|
+
- **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`).
|
|
75
|
+
- **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.
|
|
76
|
+
- **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.
|
|
77
|
+
- **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).
|
|
78
|
+
- **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`).
|
|
79
|
+
- **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.
|
|
80
|
+
- **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.
|
|
81
|
+
|
|
12
82
|
## 0.43.2 — 2026-06-02 — Agent-legible MCP contracts ([ADR 0057](docs/architecture/decisions/0057-agent-legible-mcp-contracts.md))
|
|
13
83
|
|
|
14
84
|
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
|
@@ -72,6 +72,8 @@ TRANSIENT │ feeds │ proposals (queue) │
|
|
|
72
72
|
raw material ──── propose ────► a human accept lifts it to canon
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
*(The fifth lane, `artifacts`, isn't on this grid — it's a derived **output**, computed from the lanes rather than an input climbing toward trust.)*
|
|
76
|
+
|
|
75
77
|
Without coordination, they overwrite each other and nothing remembers why. textus gives each actor a **lane** — called a **zone** in the manifest and CLI, the term used everywhere technical from here on — routes everything they can't write directly through a **proposals queue**, and writes every successful change to an **append-only audit log**. The lanes are enforced at the protocol level, not by convention.
|
|
76
78
|
|
|
77
79
|
```
|
|
@@ -91,9 +93,16 @@ That's the load-bearing claim: **coordination is a protocol invariant, not a lib
|
|
|
91
93
|
```sh
|
|
92
94
|
gem install textus
|
|
93
95
|
textus init # creates .textus/ with zones + schemas
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
|
|
97
|
+
# an agent proposes a change — it targets a knowledge entry, but lands in proposals/
|
|
98
|
+
textus propose notes.oncall --as=agent --stdin <<'JSON'
|
|
99
|
+
{
|
|
100
|
+
"_meta": { "name": "oncall",
|
|
101
|
+
"proposal": { "target_key": "knowledge.notes.oncall", "action": "put" } },
|
|
102
|
+
"body": "Patrick on call.\n"
|
|
103
|
+
}
|
|
104
|
+
JSON
|
|
105
|
+
|
|
97
106
|
# you accept it — textus promotes to knowledge/ and audits the move
|
|
98
107
|
textus accept proposals.notes.oncall --as=human
|
|
99
108
|
```
|
|
@@ -130,7 +139,7 @@ bundle exec exe/textus --help
|
|
|
130
139
|
|
|
131
140
|
## What `textus init` gives you
|
|
132
141
|
|
|
133
|
-
You get `.textus/` with all five zone directories, baseline schemas,
|
|
142
|
+
You get `.textus/` with all five zone directories, baseline schemas, a starter manifest, and a gitignored `.run/` for disposable runtime state (the audit log, per-role cursors, fetch/build locks). Roles declare capabilities; each zone declares a `kind:`, and write authority is derived from the role's capabilities crossed with the zone's kind:
|
|
134
143
|
|
|
135
144
|
```yaml
|
|
136
145
|
roles:
|
|
@@ -148,18 +157,22 @@ zones:
|
|
|
148
157
|
|
|
149
158
|
```
|
|
150
159
|
.textus/
|
|
151
|
-
manifest.yaml
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
manifest.yaml # role capabilities + zone kinds + key-to-path mapping
|
|
161
|
+
schemas/ # YAML field shapes per entry family
|
|
162
|
+
templates/ # mustache templates for derived entries
|
|
163
|
+
hooks/ # one .rb per hook
|
|
164
|
+
.gitignore # generated — ignores .run/ and any tracked:false entries
|
|
165
|
+
zones/ # one dir per zone; kinds + capabilities are in the manifest above
|
|
166
|
+
knowledge/ # e.g. identity (knowledge.identity.*), voice, decisions, notes
|
|
167
|
+
notebook/
|
|
168
|
+
feeds/
|
|
169
|
+
proposals/
|
|
170
|
+
artifacts/
|
|
171
|
+
.run/ # disposable runtime state — gitignored, safe to delete (ADR 0038)
|
|
172
|
+
audit/audit.log # append-only NDJSON event ledger, every write (rotates at ~50 MB)
|
|
173
|
+
state/cursor.<role> # per-role pulse cursor — where `pulse --since` resumes
|
|
174
|
+
locks/ build.lock # per-key fetch locks + the build mutex
|
|
175
|
+
sentinels/ # publish bookkeeping (target sha) — regenerated on build (ADR 0070)
|
|
163
176
|
```
|
|
164
177
|
|
|
165
178
|
Manifest `path:` fields are relative to `.textus/zones/`. So `knowledge.notes.org.jane` lives at `.textus/zones/knowledge/notes/org/jane.md`.
|
|
@@ -176,7 +189,7 @@ textus rule list # show every rule block
|
|
|
176
189
|
textus audit --limit=20 # query the audit log
|
|
177
190
|
```
|
|
178
191
|
|
|
179
|
-
(All verbs return JSON envelopes
|
|
192
|
+
(All verbs return JSON envelopes; `--output=json` is the default and the only format in v1.)
|
|
180
193
|
|
|
181
194
|
For a worked store — knowledge entries, a staged proposal, schemas, a template, and a build that publishes `CLAUDE.md` / `AGENTS.md` — see [`examples/project/`](examples/project/).
|
|
182
195
|
|
|
@@ -190,7 +203,7 @@ For a worked store — knowledge entries, a staged proposal, schemas, a template
|
|
|
190
203
|
|
|
191
204
|
## CLI and zones
|
|
192
205
|
|
|
193
|
-
|
|
206
|
+
Every command operates on one store, located in this order: `--root <path>` flag → **`TEXTUS_ROOT`** env → walk up from the working directory for a `.textus/` ([SPEC §3.1](SPEC.md)). Write verbs require `--as=<role>`, resolved as: `--as` flag → **`TEXTUS_ROLE`** env → `.textus/role` file → default `human` ([SPEC §5.1](SPEC.md)). Default roles: `human`, `agent`, `automation` (rename or add your own in the manifest's `roles:` block). All verbs accept `--output=json` and return the envelope defined in [SPEC §8](SPEC.md).
|
|
194
207
|
|
|
195
208
|
- Full verb table — read, write, health, scaffolding — is in [SPEC §9](SPEC.md).
|
|
196
209
|
- Zone semantics and the capability × zone-kind mapping live in [SPEC §5](SPEC.md), with the reference in [`docs/reference/zones.md`](docs/reference/zones.md).
|
|
@@ -203,17 +216,31 @@ Derived entries declare `compute: { kind: projection, select: ..., pluck: ..., s
|
|
|
203
216
|
|
|
204
217
|
For externally-generated entries, declare `compute: { kind: external, sources: [...] }` — textus tracks the declared sources for staleness; the build automation produces the file.
|
|
205
218
|
|
|
206
|
-
Publishing is one typed `publish:` block (ADR 0052). `publish: { to: [path, ...] }` byte-copies a single derived file to one or more targets. `publish: { tree: "dir" }` on a nested entry mirrors its whole stored subtree to one target directory, preserving layout (path-driven — no keys or template variables). Sentinels for every published file live under `.textus/sentinels
|
|
219
|
+
Publishing is one typed `publish:` block (ADR 0052). `publish: { to: [path, ...] }` byte-copies a single derived file to one or more targets. `publish: { tree: "dir" }` on a nested entry mirrors its whole stored subtree to one target directory, preserving layout (path-driven — no keys or template variables). Sentinels for every published file live under `.textus/.run/sentinels/` (git-ignored runtime state, regenerated on build — ADR 0070). See SPEC §5.2, §5.3, §5.12.
|
|
207
220
|
|
|
208
221
|
## Extension points
|
|
209
222
|
|
|
210
|
-
textus exposes a hook DSL. Drop `.rb` files into `.textus/hooks/` (subdirectories are fine; files load alphabetically by full path).
|
|
223
|
+
textus exposes a hook DSL. Drop `.rb` files into `.textus/hooks/` (subdirectories are fine; files load alphabetically by full path). There are two kinds:
|
|
224
|
+
|
|
225
|
+
**RPC hooks** — one handler, the framework uses what you return:
|
|
226
|
+
|
|
227
|
+
| Event | Fires when | You return |
|
|
228
|
+
|---|---|---|
|
|
229
|
+
| `:resolve_intake` | a fetch needs bytes | `{_meta:, body:}` |
|
|
230
|
+
| `:transform_rows` | a projection builds | the reshaped rows |
|
|
231
|
+
| `:validate` | `textus doctor` runs | doctor issues (or none) |
|
|
232
|
+
|
|
233
|
+
**Pub-sub hooks** — 0..N handlers, fire-and-react (no return value):
|
|
211
234
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
235
|
+
| Event(s) | Fires when |
|
|
236
|
+
|---|---|
|
|
237
|
+
| `:entry_put` · `:entry_deleted` · `:entry_renamed` | a write lands |
|
|
238
|
+
| `:entry_fetched` | a fetch-driven write lands |
|
|
239
|
+
| `:build_completed` | a derived entry materializes |
|
|
240
|
+
| `:file_published` | a derived file is copied to its target |
|
|
241
|
+
| `:proposal_accepted` · `:proposal_rejected` | a proposal is resolved |
|
|
242
|
+
| `:fetch_started` · `:fetch_failed` · `:fetch_backgrounded` | background-fetch lifecycle |
|
|
243
|
+
| `:store_loaded` | the store finishes loading |
|
|
217
244
|
|
|
218
245
|
```ruby
|
|
219
246
|
# Inside .textus/hooks/local_file.rb
|
|
@@ -239,9 +266,9 @@ end
|
|
|
239
266
|
To keep a batch of stale intake entries current in one shot:
|
|
240
267
|
|
|
241
268
|
```sh
|
|
242
|
-
textus fetch
|
|
243
|
-
# or just fetch everything
|
|
244
|
-
textus fetch
|
|
269
|
+
textus fetch all --prefix=feeds --zone=feeds --as=automation
|
|
270
|
+
# or just fetch everything past its TTL in the feeds zone:
|
|
271
|
+
textus fetch all --zone=feeds --as=automation
|
|
245
272
|
```
|
|
246
273
|
|
|
247
274
|
See SPEC.md §5.10 for the full hook contract.
|
|
@@ -273,4 +300,4 @@ Lefthook hooks (`brew bundle install` then `lefthook install`) run rubocop on `p
|
|
|
273
300
|
|
|
274
301
|
## License
|
|
275
302
|
|
|
276
|
-
[MIT](LICENSE)
|
|
303
|
+
[MIT](LICENSE)
|
data/SPEC.md
CHANGED
|
@@ -100,7 +100,7 @@ textus is organized as five composable layers. Each layer has a single responsib
|
|
|
100
100
|
| L1 | **Store** | Plain-file backend: `.textus/zones/<zone>/...` with YAML frontmatter + Markdown body, addressed by dotted keys, schema-validated, etag-versioned. |
|
|
101
101
|
| L2 | **Sources** | Declared external inputs (the `feeds` zone in the default scaffold; any `quarantine` zone, writable by a role with `fetch`): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external automation fetches and pipes results through `textus put`. |
|
|
102
102
|
| L3 | **Compute** | Pure transforms from store entries to derived entries. Projections (select/pluck/sort/limit/format) plus a vendored Mustache template subset. No shell execution. |
|
|
103
|
-
| L4 | **Publish** | Byte-for-byte file copy from derived entries to repo-relative paths declared via `publish: { to: [...] }`. The in-store artifact is the consumer-shaped output; the published file is an identical copy. A sentinel under `.textus/sentinels/<target-rel-path>.textus-managed.json` records the source, sha256, and `mode: "copy"`. |
|
|
103
|
+
| L4 | **Publish** | Byte-for-byte file copy from derived entries to repo-relative paths declared via `publish: { to: [...] }`. The in-store artifact is the consumer-shaped output; the published file is an identical copy. A sentinel under `.textus/.run/sentinels/<target-rel-path>.textus-managed.json` (git-ignored runtime state) records the source, sha256, and `mode: "copy"`. |
|
|
104
104
|
| L5 | **Consumers** | Anything that reads the published files or calls the CLI — editors, LLM tools, MCP servers, CI jobs, dashboards. textus is agnostic about who consumes; the envelope is the contract. |
|
|
105
105
|
|
|
106
106
|
## 2. Goals and non-goals
|
|
@@ -135,7 +135,7 @@ The root is `.textus/` at the project working directory. A typical tree:
|
|
|
135
135
|
schemas/ # internal: YAML schema files
|
|
136
136
|
templates/ # internal: Mustache templates referenced by derived entries
|
|
137
137
|
hooks/ # internal: one Ruby file per hook
|
|
138
|
-
sentinels/
|
|
138
|
+
.run/sentinels/ # runtime (git-ignored): byte-copied publish bookkeeping, regenerated on build (see §5.3)
|
|
139
139
|
zones/ # ALL user content lives here
|
|
140
140
|
knowledge/ # zone: knowledge (kind: canon — author-holders write; knowledge.identity.* is the identity convention)
|
|
141
141
|
notebook/ # zone: notebook (kind: workspace — keep-holders write; agent's own durable lane)
|
|
@@ -144,7 +144,7 @@ The root is `.textus/` at the project working directory. A typical tree:
|
|
|
144
144
|
artifacts/ # zone: artifacts (kind: derived — build-holders write)
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
-
Textus internals (`manifest.yaml`, `
|
|
147
|
+
Textus internals (`manifest.yaml`, `schemas/`, `templates/`, `hooks/`) live directly under `.textus/`; disposable runtime state (the audit log, publish `sentinels/`, fetch/build locks, pulse cursors) lives under `.textus/.run/` (git-ignored, ADR 0038/0070). **All user content lives under `.textus/zones/`.** Manifest `path:` fields are relative to `.textus/zones/` — they do **not** include the `zones/` prefix. Implementations MUST prepend `zones/` to every `path:` when resolving a key to a filesystem location.
|
|
148
148
|
|
|
149
149
|
Zone directories under `zones/` are conventional; their write semantics are derived from the zone's declared `kind:` (and the capabilities roles hold), not the directory name.
|
|
150
150
|
|
|
@@ -450,13 +450,13 @@ publish:
|
|
|
450
450
|
|
|
451
451
|
When the entry is recomputed, textus copies the in-store file byte-for-byte to each destination. The in-store artifact under `.textus/zones/<output-zone>/…` is already the consumer-shaped output (per the format strategy — see §5.x), so publish is a verbatim file copy with no parsing or stripping.
|
|
452
452
|
|
|
453
|
-
A sentinel is written for each published file at `<store_root
|
|
453
|
+
A sentinel is written for each published file at `<store_root>/.run/sentinels/<target-relative-to-repo>.textus-managed.json` (git-ignored runtime state — ADR 0070), recording `source`, `target`, the target's sha256, and `mode: "copy"`. Sentinels live under the store's runtime tree rather than beside the consumer file so target directories stay clean, and are regenerated by the next build (via content-identical adoption) rather than committed. The sentinel exists so out-of-band edits can be detected on the next publish — textus refuses to clobber a destination that is not either missing, marked as managed, or **byte-identical to the source being published**. An identical destination is *adopted*: its sentinel is written and management proceeds (the copy is a content no-op), so an artifact tree already on disk onboards without a manual delete. An unmanaged destination whose content **differs**, or any unmanaged symlink, is still refused (ADR 0050). Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
|
|
454
454
|
|
|
455
|
-
**Subtree mirror.** A nested entry MAY declare `publish: { tree: "dir" }` instead of `to:` (see §4). On every build, textus walks the entry's full stored subtree (`zones/<path>/**`), applies the entry's `ignore:` filter, and byte-copies each file to the target directory, preserving relative layout — one sentinel per file under `<store_root
|
|
455
|
+
**Subtree mirror.** A nested entry MAY declare `publish: { tree: "dir" }` instead of `to:` (see §4). On every build, textus walks the entry's full stored subtree (`zones/<path>/**`), applies the entry's `ignore:` filter, and byte-copies each file to the target directory, preserving relative layout — one sentinel per file under `<store_root>/.run/sentinels/`. The mirror is path-driven: no keys are enumerated, no template variables are interpreted, and mirrored files are opaque payload (never addressable). On rebuild, the entire target directory is pruned of textus-managed files the current source no longer produces; unmanaged files are never touched. The build envelope grows a `published_leaves` array — one row per mirrored file, with `key`, `source`, and `target` — alongside the existing `built` array, plus a `pruned` array listing any orphaned managed files removed on this build. Targets that would resolve outside the repo root are refused. When a `publish.tree` target overlaps a `derived` entry's `publish.to` (e.g. a derived `SKILL.md` written into the mirrored dir), the mirroring entry must `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap` (ADR 0047).
|
|
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
|
|
|
@@ -735,10 +735,10 @@ An entry's `format:` selects a storage strategy. All strategies expose the same
|
|
|
735
735
|
**`_meta` convention.** Derived structured entries (json, yaml) embed a `_meta` hash as the first top-level key. Builder-injected keys appear in a fixed order for etag stability:
|
|
736
736
|
|
|
737
737
|
```
|
|
738
|
-
|
|
738
|
+
from, template, transform
|
|
739
739
|
```
|
|
740
740
|
|
|
741
|
-
Keys with `nil` values are omitted. User-shaped content (or the reducer's hash) follows `_meta`. The etag (§10) is the sha256 of the on-disk bytes regardless of format; key ordering MUST therefore be deterministic, which Ruby's `Hash` and `JSON.generate` / `YAML.dump` honor via insertion order.
|
|
741
|
+
Keys with `nil` values are omitted. The builder injects only **deterministic** provenance: it does **not** stamp a `generated_at` build timestamp into the artifact (ADR 0070). A built artifact is content-addressed — rebuilding unchanged sources reproduces it byte-for-byte, so a rebuild is a no-op and a `git` revert never drifts. (The `generated.at` of §5.2 is a separate convention written by *external* build tools, not by textus's own builder.) User-shaped content (or the reducer's hash) follows `_meta`. The etag (§10) is the sha256 of the on-disk bytes regardless of format; key ordering MUST therefore be deterministic, which Ruby's `Hash` and `JSON.generate` / `YAML.dump` honor via insertion order.
|
|
742
742
|
|
|
743
743
|
## 6. Schemas
|
|
744
744
|
|
|
@@ -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": ["accept", "delete", "fetch", "fetch_all", "mv", "propose", "put", "reject"],
|
|
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). All of these apply by default; `dry_run: true` is a uniform opt-in preview that returns a Plan without mutating (ADR 0071 — verbs are actions, dry-run is opt-in on every surface). Single-key `delete` additionally accepts an optional `if_etag` optimistic-concurrency check. The blast-radius reads (`where`/`deps`/`rdeps`) remain on MCP so an agent can look before it leaps. The promotion verbs `accept` and `reject` are also on MCP (ADR 0072): they are gated by the `author_held` capability floor, not by transport absence — a default-`agent` connection is refused, while a connection launched as a role holding `author` (`--as`/`TEXTUS_ROLE`/`.textus/role`, resolved once at launch per ADR 0040) can promote, closing the propose→accept loop over one transport.
|
|
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
|
|
|
@@ -1008,13 +1010,13 @@ Given the `person` schema and a `put` whose frontmatter omits `relationship`, th
|
|
|
1008
1010
|
Given a manifest entry `intake.notes` matched by a `rules: [{ match: intake.notes, fetch: { ttl: 1h } }]` block and an envelope on disk whose `_meta.last_fetched_at` is older than `now - ttl`, `textus freshness --output=json` includes a row for `intake.notes` with `status: "stale"`. Calling `textus freshness` does NOT trigger a fetch.
|
|
1009
1011
|
|
|
1010
1012
|
**Fixture E — Projection build:**
|
|
1011
|
-
Given a manifest entry `derived.catalogs.skills` whose `compute: { kind: projection }` clause selects fields from `working.projects` entries, `textus build derived.catalogs.skills` materializes the derived entry on disk with frontmatter and body matching the projected shape
|
|
1013
|
+
Given a manifest entry `derived.catalogs.skills` whose `compute: { kind: projection }` clause selects fields from `working.projects` entries, `textus build derived.catalogs.skills` materializes the derived entry on disk with frontmatter and body matching the projected shape. The output is content-addressed (no `generated_at` timestamp, ADR 0070), so rebuilding with unchanged sources reproduces it byte-for-byte and writes nothing.
|
|
1012
1014
|
|
|
1013
1015
|
**Fixture F — Mustache render:**
|
|
1014
1016
|
Given a derived entry with a `template` clause referencing a `.mustache` file and inputs drawn from other keys, `textus build` produces a body whose contents match the expected rendered output byte-for-byte (after trailing-newline normalization).
|
|
1015
1017
|
|
|
1016
1018
|
**Fixture G — Copy publish:**
|
|
1017
|
-
Given a manifest entry with `publish: { to: [<path>] }`, a successful `textus build` for that entry leaves a plain file at `<path>` whose contents are byte-identical to the in-store artifact at `.textus/zones/<...>`, accompanied by a sentinel at `.textus/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `build` is idempotent.
|
|
1019
|
+
Given a manifest entry with `publish: { to: [<path>] }`, a successful `textus build` for that entry leaves a plain file at `<path>` whose contents are byte-identical to the in-store artifact at `.textus/zones/<...>`, accompanied by a sentinel at `.textus/.run/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `build` is idempotent.
|
|
1018
1020
|
|
|
1019
1021
|
**Fixture H — Audit log format:**
|
|
1020
1022
|
Every successful write verb (`put`, `delete`, `build`, `accept`, `schema migrate`) appends exactly one line per affected key to the audit log, in the canonical format defined in §audit (timestamp, actor role, verb, key, etag-before, etag-after). No write produces zero or multiple lines per key.
|
|
@@ -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
|
|
|
@@ -226,31 +245,12 @@ The agent loop (cadence guide in [`agents-mcp.md`](../how-to/agents-mcp.md)):
|
|
|
226
245
|
|
|
227
246
|
1. **Session start:** `boot()` → contract envelope (zones, entries, schemas, write_flows, agent_quickstart with `latest_seq`).
|
|
228
247
|
2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor}`.
|
|
229
|
-
3. **On demand:** `get`, `put`, `propose`, `fetch`, `
|
|
248
|
+
3. **On demand:** `get`, `put`, `propose`, `fetch`, `schema_show`, `rule_explain`.
|
|
230
249
|
|
|
231
250
|
Manifest drift surfaces as `ContractDrift` (manifest_etag mismatch); audit cursor falls off the keep window as `CursorExpired`. Both signal "call `boot` again."
|
|
232
251
|
|
|
233
252
|
## Hooks event catalog
|
|
234
253
|
|
|
235
|
-
`Hooks::Signature` is the single home of callable keyword-introspection — both `EventBus` (pub-sub dispatch) and `RpcRegistry` (RPC dispatch) delegate to it for `accepts_keyrest?`, `declared_keys`, `missing`, and `filter` rather than each maintaining a hand-rolled copy (ADR 0027).
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
- `resolve_intake(caps:, config:, args:)` — intake fetch handler.
|
|
239
|
-
- `transform_rows(caps:, rows:, config:)` — row transform for intakes.
|
|
240
|
-
- `validate(caps:)` — custom doctor validator.
|
|
241
|
-
|
|
242
|
-
Pub-sub (0..N handlers, declare `ctx:`):
|
|
243
|
-
- `entry_put(ctx:, key:, envelope:)`
|
|
244
|
-
- `entry_deleted(ctx:, key:)`
|
|
245
|
-
- `entry_fetched(ctx:, key:, envelope:, change:)`
|
|
246
|
-
- `entry_renamed(ctx:, key:, from_key:, to_key:, envelope:)`
|
|
247
|
-
- `build_completed(ctx:, key:, envelope:, sources:)`
|
|
248
|
-
- `proposal_accepted(ctx:, key:, target_key:)`
|
|
249
|
-
- `proposal_rejected(ctx:, key:, target_key:)`
|
|
250
|
-
- `file_published(ctx:, key:, envelope:, source:, target:)`
|
|
251
|
-
- `store_loaded(ctx:)`
|
|
252
|
-
- `fetch_started(ctx:, key:, mode:)`
|
|
253
|
-
- `fetch_failed(ctx:, key:, error_class:, error_message:)`
|
|
254
|
-
- `fetch_backgrounded(ctx:, key:, started_at:, budget_ms:)`
|
|
255
|
-
|
|
256
|
-
Authoritative source: `lib/textus/hooks/catalog.rb` (`Catalog::RPC` and `Catalog::PUBSUB`).
|
|
254
|
+
`Hooks::Signature` is the single home of callable keyword-introspection — both `EventBus` (pub-sub dispatch) and `RpcRegistry` (RPC dispatch) delegate to it for `accepts_keyrest?`, `declared_keys`, `missing`, and `filter` rather than each maintaining a hand-rolled copy (ADR 0027). RPC handlers declare `caps:` (single handler); pub-sub handlers declare `ctx:` (0..N handlers).
|
|
255
|
+
|
|
256
|
+
The event names, payloads, and per-verb firing order are documented once in [`reference/events.md`](../reference/events.md) (the friendly SSoT); the authoritative source is `lib/textus/hooks/catalog.rb` (`Catalog::RPC` and `Catalog::PUBSUB`).
|
|
@@ -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
|
|