textus 0.20.2 → 0.26.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/ARCHITECTURE.md +148 -45
- data/CHANGELOG.md +194 -0
- data/README.md +8 -5
- data/SPEC.md +54 -15
- data/docs/conventions.md +10 -0
- data/lib/textus/application/caps.rb +49 -0
- data/lib/textus/application/context.rb +2 -2
- data/lib/textus/application/envelope/reader.rb +44 -0
- data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
- data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
- data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
- data/lib/textus/application/maintenance/migrate.rb +59 -0
- data/lib/textus/application/maintenance/rule_lint.rb +65 -0
- data/lib/textus/application/maintenance/zone_mv.rb +60 -0
- data/lib/textus/application/maintenance.rb +17 -0
- data/lib/textus/application/projection.rb +12 -10
- data/lib/textus/application/read/audit.rb +106 -0
- data/lib/textus/application/read/blame.rb +91 -0
- data/lib/textus/application/read/deps.rb +34 -0
- data/lib/textus/application/read/freshness.rb +110 -0
- data/lib/textus/application/read/get.rb +75 -0
- data/lib/textus/application/read/get_or_refresh.rb +63 -0
- data/lib/textus/application/read/list.rb +25 -0
- data/lib/textus/application/read/policy_explain.rb +47 -0
- data/lib/textus/application/read/published.rb +25 -0
- data/lib/textus/application/read/pulse.rb +101 -0
- data/lib/textus/application/read/rdeps.rb +35 -0
- data/lib/textus/application/read/schema_envelope.rb +26 -0
- data/lib/textus/application/read/stale.rb +23 -0
- data/lib/textus/application/read/uid.rb +30 -0
- data/lib/textus/application/read/validate_all.rb +32 -0
- data/lib/textus/application/{reads → read}/validator.rb +2 -2
- data/lib/textus/application/read/where.rb +26 -0
- data/lib/textus/application/use_case.rb +22 -0
- data/lib/textus/application/write/accept.rb +102 -0
- data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
- data/lib/textus/application/write/delete.rb +45 -0
- data/lib/textus/application/{writes → write}/materializer.rb +14 -15
- data/lib/textus/application/write/mv.rb +118 -0
- data/lib/textus/application/write/publish.rb +96 -0
- data/lib/textus/application/write/put.rb +49 -0
- data/lib/textus/application/write/refresh_all.rb +63 -0
- data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
- data/lib/textus/application/write/refresh_worker.rb +134 -0
- data/lib/textus/application/write/reject.rb +62 -0
- data/lib/textus/{intro.rb → boot.rb} +49 -29
- data/lib/textus/builder/pipeline.rb +5 -5
- data/lib/textus/cli/group/mcp.rb +9 -0
- data/lib/textus/cli/group/zone.rb +9 -0
- data/lib/textus/cli/verb/accept.rb +1 -1
- data/lib/textus/cli/verb/audit.rb +4 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +13 -0
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/delete.rb +1 -1
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -1
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -4
- data/lib/textus/cli/verb/hooks.rb +11 -14
- data/lib/textus/cli/verb/key_delete.rb +24 -0
- data/lib/textus/cli/verb/list.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +17 -0
- data/lib/textus/cli/verb/migrate.rb +18 -0
- data/lib/textus/cli/verb/mv.rb +11 -3
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/pulse.rb +17 -0
- data/lib/textus/cli/verb/put.rb +8 -6
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +1 -1
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/rule_explain.rb +1 -1
- data/lib/textus/cli/verb/rule_lint.rb +18 -0
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb/uid.rb +1 -1
- data/lib/textus/cli/verb/where.rb +1 -1
- data/lib/textus/cli/verb/zone_mv.rb +19 -0
- data/lib/textus/cli/verb.rb +4 -4
- data/lib/textus/cli.rb +1 -1
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
- data/lib/textus/doctor/check/hooks.rb +4 -3
- data/lib/textus/doctor/check/illegal_keys.rb +2 -2
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +2 -2
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/refresh_locks.rb +2 -2
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/schemas.rb +2 -2
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +2 -2
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +5 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +4 -4
- data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
- data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
- data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
- data/lib/textus/domain/staleness/generator_check.rb +2 -2
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/domain/staleness.rb +1 -1
- data/lib/textus/errors.rb +16 -0
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +13 -13
- data/lib/textus/hooks/error_log.rb +32 -0
- data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
- data/lib/textus/hooks/loader.rb +29 -3
- data/lib/textus/hooks/rpc_registry.rb +77 -0
- data/lib/textus/infra/audit_log.rb +126 -16
- data/lib/textus/infra/audit_subscriber.rb +6 -7
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/key/path.rb +7 -3
- data/lib/textus/manifest/data.rb +78 -0
- data/lib/textus/manifest/entry/base.rb +44 -7
- data/lib/textus/manifest/entry/derived.rb +41 -6
- data/lib/textus/manifest/entry/intake.rb +15 -3
- data/lib/textus/manifest/entry/leaf.rb +6 -5
- data/lib/textus/manifest/entry/nested.rb +42 -3
- data/lib/textus/manifest/entry/parser.rb +8 -44
- data/lib/textus/manifest/entry/validators/events.rb +2 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
- data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/entry.rb +3 -0
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +18 -18
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest/schema.rb +20 -6
- data/lib/textus/manifest.rb +53 -101
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +127 -0
- data/lib/textus/mcp/session.rb +31 -0
- data/lib/textus/mcp/tool_schemas.rb +71 -0
- data/lib/textus/mcp/tools.rb +129 -0
- data/lib/textus/mcp.rb +6 -0
- data/lib/textus/schema/tools.rb +14 -10
- data/lib/textus/session.rb +84 -0
- data/lib/textus/store.rb +17 -8
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +8 -1
- metadata +65 -38
- data/lib/textus/application/reads/audit.rb +0 -69
- data/lib/textus/application/reads/blame.rb +0 -82
- data/lib/textus/application/reads/deps.rb +0 -26
- data/lib/textus/application/reads/freshness.rb +0 -88
- data/lib/textus/application/reads/get.rb +0 -67
- data/lib/textus/application/reads/get_or_refresh.rb +0 -51
- data/lib/textus/application/reads/list.rb +0 -17
- data/lib/textus/application/reads/policy_explain.rb +0 -39
- data/lib/textus/application/reads/published.rb +0 -17
- data/lib/textus/application/reads/rdeps.rb +0 -27
- data/lib/textus/application/reads/schema_envelope.rb +0 -18
- data/lib/textus/application/reads/stale.rb +0 -15
- data/lib/textus/application/reads/uid.rb +0 -23
- data/lib/textus/application/reads/validate_all.rb +0 -24
- data/lib/textus/application/reads/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/mv.rb +0 -105
- data/lib/textus/application/writes/publish.rb +0 -162
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/cli/verb/intro.rb +0 -13
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
- data/lib/textus/operations.rb +0 -169
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e822d94de7ae06481dd8c03eab813a097b62e78121d94c9d1045017675da665c
|
|
4
|
+
data.tar.gz: 3372f74bb31465b28be8e6ce5b4721a3f15cae7536ebdfc8ef9b6c6d7c9f2d88
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 31e4b83f9a7d3b061d043c447393b23ba404a47e7d79b0a411ad49719625d01ec30116a067f28bf88ac6944ff0262c1825d35c851022340d63b813dca26f98aa
|
|
7
|
+
data.tar.gz: 590cc3419cb7823688bab11c4fce49a011bf62b463ab887b415753dd3c8037cd0704b8ccc4df6e778b21c04bd8101847a28fa5d4819e447e7af3d44febc017f3
|
data/ARCHITECTURE.md
CHANGED
|
@@ -2,88 +2,191 @@
|
|
|
2
2
|
|
|
3
3
|
```
|
|
4
4
|
┌─ Interface ────────────────────────────────────────────────┐
|
|
5
|
-
│ CLI verbs:
|
|
6
|
-
│
|
|
7
|
-
│ # case
|
|
5
|
+
│ CLI verbs: session = Session.for(store, role:) │
|
|
6
|
+
│ session.<name>(...) # one method per │
|
|
7
|
+
│ # registered use case │
|
|
8
|
+
│ # (put/get/refresh/…) │
|
|
8
9
|
│ │
|
|
9
|
-
│
|
|
10
|
-
│ Operations.new(ctx:, manifest:, file_store:, │
|
|
11
|
-
│ schemas:, audit_log:, bus:, │
|
|
12
|
-
│ registry:, root:, store:) │
|
|
10
|
+
│ MCP gate: textus mcp serve — same use cases, JSON-RPC. │
|
|
13
11
|
└──────────────────────┬─────────────────────────────────────┘
|
|
14
12
|
│
|
|
15
13
|
┌─ Application ────────▼─────────────────────────────────────┐
|
|
16
14
|
│ Context (slim Data: role, correlation_id, now, │
|
|
17
15
|
│ dry_run — request state only) │
|
|
18
|
-
│
|
|
16
|
+
│ Caps (Read/Write/Hook records — store slices) │
|
|
17
|
+
│ Session (per-call dispatch; methods generated │
|
|
18
|
+
│ from UseCase registry) │
|
|
19
|
+
│ UseCase (registry: verb → module, caps_kind) │
|
|
19
20
|
│ │
|
|
20
|
-
│
|
|
21
|
-
│
|
|
22
|
-
│
|
|
23
|
-
│
|
|
24
|
-
│
|
|
25
|
-
│
|
|
26
|
-
│
|
|
21
|
+
│ read/{get,get_or_refresh,list,where,uid,schema_envelope, │
|
|
22
|
+
│ deps,rdeps,published,stale,validate_all, │
|
|
23
|
+
│ freshness,audit,blame,policy_explain,pulse}.rb │
|
|
24
|
+
│ write/{put,delete,mv,accept,reject,publish, │
|
|
25
|
+
│ materializer,authority_gate, │
|
|
26
|
+
│ refresh_worker,refresh_orchestrator,refresh_all} │
|
|
27
|
+
│ maintenance/{migrate,key_mv_prefix,key_delete_prefix, │
|
|
28
|
+
│ zone_mv,rule_lint}.rb │
|
|
29
|
+
│ envelope/{reader,writer}.rb (split: parse vs persist) │
|
|
30
|
+
│ projection.rb │
|
|
27
31
|
└──────────┬───────────────────────────────┬─────────────────┘
|
|
28
32
|
│ uses domain │ uses ports
|
|
29
33
|
┌─ Domain ─▼─────────────────────────────────────────────────┐
|
|
30
34
|
│ Authorizer (manifest + role → allow / deny) │
|
|
31
35
|
│ Permission (write/read predicate per zone) │
|
|
32
36
|
│ Freshness::{Policy,Verdict,Evaluator} │
|
|
33
|
-
│
|
|
34
|
-
│
|
|
37
|
+
│ Staleness (Generator/Intake checks) │
|
|
38
|
+
│ Action Outcome Sentinel │
|
|
39
|
+
│ Policy::{Promote,Refresh,Matcher,HandlerAllowlist, │
|
|
40
|
+
│ Predicates::{SchemaValid,AcceptAuthoritySigned}} │
|
|
35
41
|
└──────────────────────────────────────────┬─────────────────┘
|
|
36
42
|
│ implements
|
|
37
43
|
┌─ Infrastructure ─────────────────────────▼─────────────────┐
|
|
38
|
-
│ Store (composition root — wires ports
|
|
44
|
+
│ Store (composition root — wires ports, │
|
|
45
|
+
│ vends Sessions) │
|
|
39
46
|
│ Storage::FileStore (bytes-only port: read/write/delete/ │
|
|
40
47
|
│ exists?/etag) │
|
|
41
|
-
│ Manifest (
|
|
48
|
+
│ Manifest (Data, Resolver, Policy, Rules) │
|
|
42
49
|
│ Schemas (eager-load cache) │
|
|
43
|
-
│ AuditLog
|
|
44
|
-
│
|
|
45
|
-
│
|
|
46
|
-
│
|
|
50
|
+
│ Infra::{AuditLog,AuditSubscriber,Publisher,Clock, │
|
|
51
|
+
│ Refresh::Lock,Refresh::Detached,BuildLock} │
|
|
52
|
+
│ Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport, │
|
|
53
|
+
│ Builtin,ErrorLog} │
|
|
47
54
|
│ Entry::{Markdown,Json,Yaml,Text} (format strategies) │
|
|
48
55
|
└────────────────────────────────────────────────────────────┘
|
|
49
56
|
|
|
50
57
|
Dependency rule: arrows point DOWN. Domain has zero outbound
|
|
51
58
|
imports. Application imports Domain + Infra (via ports).
|
|
52
|
-
Use cases declare their real
|
|
59
|
+
Use cases declare their real collaborators in their Impl
|
|
60
|
+
constructor; UseCase.register hooks them into Session.
|
|
53
61
|
```
|
|
54
62
|
|
|
55
|
-
##
|
|
63
|
+
## How a verb becomes a method
|
|
56
64
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
Each application use case is a module under `lib/textus/application/{read,write,maintenance}/`. The shape is uniform:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
module Textus
|
|
69
|
+
module Application
|
|
70
|
+
module Read
|
|
71
|
+
module Get
|
|
72
|
+
def self.call(*, session:, ctx:, caps:, **)
|
|
73
|
+
Impl.new(ctx: ctx, caps: caps).call(*, **)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class Impl
|
|
77
|
+
def initialize(ctx:, caps:, ...)
|
|
78
|
+
@ctx = ctx; @manifest = caps.manifest; ...
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def call(key) ... end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
Textus::Application::UseCase.register(:get, Textus::Application::Read::Get, caps: :read)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`Session` generates one dispatch method per registered entry (see `lib/textus/session.rb` — the `Application::UseCase.each do |entry| ... end` block at the bottom). Adding a new verb is **one `UseCase.register` line** plus the module — no edits to `Session`.
|
|
92
|
+
|
|
93
|
+
Two collaborators live outside the registry because they're composed by other use cases, not invoked as verbs:
|
|
94
|
+
|
|
95
|
+
- `Application::Write::RefreshOrchestrator` — composes `RefreshWorker` with the freshness `Action` returned by `Domain::Freshness`. Session memoizes one (`session.refresh_orchestrator`).
|
|
96
|
+
- `Application::Envelope::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`. Session memoizes both.
|
|
97
|
+
|
|
98
|
+
## Caps
|
|
99
|
+
|
|
100
|
+
Use cases never see the raw `Store`. `Application::Caps` defines three role-scoped slices:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
ReadCaps = Data.define(:manifest, :file_store, :schemas, :root, :audit_log, :events)
|
|
104
|
+
WriteCaps = Data.define(:manifest, :file_store, :schemas, :root, :audit_log, :events, :authorizer)
|
|
105
|
+
HookCaps = Data.define(:events, :rpc, :manifest, :root)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`Session.for(store, role:)` builds all three via `Application.caps_from_store(store)`; the dispatch method picks `read_caps` or `write_caps` based on the `caps_kind` declared at registration time. RPC hook callables (`:resolve_intake`, `:transform_rows`, `:validate`) receive a `caps:` kwarg that is the appropriate Read/Write slice — legacy `store:` is rejected by `Hooks::RpcRegistry#invoke`.
|
|
109
|
+
|
|
110
|
+
## Read path (`session.get(key)`)
|
|
111
|
+
|
|
112
|
+
1. CLI verb (or MCP tool) builds `session = Session.for(store, role:)` then `session.get(key)`.
|
|
113
|
+
2. `Session#get` dispatches to `Application::Read::Get.call(key, session:, ctx:, caps:)`.
|
|
114
|
+
3. `Read::Get::Impl#call` resolves the path through `caps.manifest`, reads bytes via `caps.file_store`, parses the envelope.
|
|
115
|
+
4. Looks up the refresh policy via `caps.manifest.rules.for(key)`. If absent, returns the envelope annotated fresh.
|
|
61
116
|
5. Otherwise `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`; the envelope is annotated with `stale`, `reason`, `refreshing: false`.
|
|
62
117
|
|
|
63
|
-
`
|
|
118
|
+
`session.get_or_refresh(key)` composes `Read::Get` with `Write::RefreshOrchestrator` to optionally refresh on stale.
|
|
64
119
|
|
|
65
|
-
## Write path (`
|
|
120
|
+
## Write path (`session.put(key, ...)`)
|
|
66
121
|
|
|
67
|
-
1. CLI verb calls `
|
|
68
|
-
2. `
|
|
69
|
-
3. Delegates persistence to `
|
|
70
|
-
4. Publishes `:entry_put` via
|
|
122
|
+
1. CLI verb calls `session = Session.for(store, role:)` then `session.put(key, meta:, body:, content:, if_etag:)`.
|
|
123
|
+
2. `Write::Put::Impl#call` validates the key, resolves the manifest entry, and calls `@authorizer.authorize_write!(mentry, role: @ctx.role)` — raises `WriteForbidden` if denied.
|
|
124
|
+
3. Delegates persistence to `session.envelope_writer.put`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
|
|
125
|
+
4. Publishes `:entry_put` via `caps.events` with `ctx: session.hook_context`, `key:`, `envelope:`.
|
|
71
126
|
|
|
72
|
-
`
|
|
127
|
+
`Write::{Delete,Mv,Accept,Reject,Publish}` follow the same shape: explicit caps, `Authorizer` for authz, `Envelope::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
|
|
73
128
|
|
|
74
|
-
`
|
|
129
|
+
`Write::Mv` delegates the file-move + audit to `Envelope::Writer#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `Envelope::Writer#write` directly — no `Put` bypass.
|
|
75
130
|
|
|
76
|
-
## Refresh path (`
|
|
131
|
+
## Refresh path (`session.refresh(key)`)
|
|
77
132
|
|
|
78
|
-
1. CLI `Verb::Refresh` builds `
|
|
79
|
-
2. `
|
|
80
|
-
- Resolves the manifest entry, looks up the intake handler via
|
|
81
|
-
- Publishes `:refresh_started` with
|
|
133
|
+
1. CLI `Verb::Refresh` builds `session = Session.for(store, role: "runner")` then calls `session.refresh(key)`.
|
|
134
|
+
2. `Write::RefreshWorker::Impl#run(key)`:
|
|
135
|
+
- Resolves the manifest entry, looks up the intake handler via `caps.rpc.callable(:resolve_intake, mentry.handler)`.
|
|
136
|
+
- Publishes `:refresh_started` with the hook context.
|
|
82
137
|
- Invokes the handler under a 30s thread-join deadline.
|
|
83
138
|
- On any error: publishes `:refresh_failed`, then re-raises.
|
|
84
|
-
- On success: applies `@authorizer.authorize_write!` and persists via `
|
|
85
|
-
3. `
|
|
139
|
+
- On success: applies `@authorizer.authorize_write!` and persists via `Envelope::Writer#write` directly (no `Put` round-trip); publishes `:entry_refreshed` unless etag is unchanged.
|
|
140
|
+
3. `session.refresh_all(prefix:, zone:)` lists stale entries via `Read::Stale` and runs `Worker#run` per entry; returns `{ refreshed:, failed:, skipped: }`.
|
|
86
141
|
|
|
87
142
|
## Hook payload contract
|
|
88
143
|
|
|
89
|
-
|
|
144
|
+
Pub-sub hooks (`:entry_put`, `:entry_refreshed`, …) receive `ctx:` — a `Textus::Hooks::Context` that wraps the session and exposes a narrow surface (`get`, `list`, `put`, `delete`, `audit`, `publish_followup`, plus `role` and `correlation_id`). The raw `Store` is not handed out.
|
|
145
|
+
|
|
146
|
+
RPC hooks (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps:` — a `ReadCaps` or `WriteCaps` slice. They are gem-internal: the framework calls them, not user pub-sub.
|
|
147
|
+
|
|
148
|
+
## Agent surface (boot + pulse + MCP)
|
|
149
|
+
|
|
150
|
+
Agents and plugins talk to a textus store through three layers:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Session ──▶ memory (.textus/)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Two transports, one façade:
|
|
157
|
+
|
|
158
|
+
- **CLI** — human/script surface. `textus boot`, `textus pulse --since=N`, `textus get/put/...`.
|
|
159
|
+
- **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. Tools are auto-derived from the manifest. Session state (cursor, role, manifest_etag) is server-side.
|
|
160
|
+
|
|
161
|
+
Both transports call `Session.for(store, role:)`. No duplicate logic.
|
|
162
|
+
|
|
163
|
+
The agent loop (cadence guide in `docs/agent-integration.md`):
|
|
164
|
+
|
|
165
|
+
1. **Session start:** `boot()` → contract envelope (zones, entries, schemas, write_flows, agent_quickstart with `latest_seq`).
|
|
166
|
+
2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor}`.
|
|
167
|
+
3. **On demand:** `get`, `put`, `propose`, `refresh`, `schema`, `rules`.
|
|
168
|
+
|
|
169
|
+
Manifest drift surfaces as `ContractDrift` (manifest_etag mismatch); audit cursor falls off the keep window as `CursorExpired`. Both signal "call `boot` again."
|
|
170
|
+
|
|
171
|
+
## Hooks::EventBus event catalog
|
|
172
|
+
|
|
173
|
+
RPC (single handler, declares `caps:`):
|
|
174
|
+
- `resolve_intake(caps:, config:, args:)` — intake fetch handler.
|
|
175
|
+
- `transform_rows(caps:, rows:, config:)` — row transform for intakes.
|
|
176
|
+
- `validate(caps:)` — custom doctor validator.
|
|
177
|
+
|
|
178
|
+
Pub-sub (0..N handlers, declare `ctx:`):
|
|
179
|
+
- `entry_put(ctx:, key:, envelope:)`
|
|
180
|
+
- `entry_deleted(ctx:, key:)`
|
|
181
|
+
- `entry_refreshed(ctx:, key:, envelope:, change:)`
|
|
182
|
+
- `entry_renamed(ctx:, key:, from_key:, to_key:, envelope:)`
|
|
183
|
+
- `build_completed(ctx:, key:, envelope:, sources:)`
|
|
184
|
+
- `proposal_accepted(ctx:, key:, target_key:)`
|
|
185
|
+
- `proposal_rejected(ctx:, key:, target_key:)`
|
|
186
|
+
- `file_published(ctx:, key:, envelope:, source:, target:)`
|
|
187
|
+
- `store_loaded(ctx:)`
|
|
188
|
+
- `refresh_started(ctx:, key:, mode:)`
|
|
189
|
+
- `refresh_failed(ctx:, key:, error_class:, error_message:)`
|
|
190
|
+
- `refresh_backgrounded(ctx:, key:, started_at:, budget_ms:)`
|
|
191
|
+
|
|
192
|
+
Authoritative source: `lib/textus/hooks/event_bus.rb` `EVENTS`.
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,200 @@ 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.26.0 — 2026-05-28
|
|
13
|
+
|
|
14
|
+
### Breaking
|
|
15
|
+
- Split `Textus::Hooks::Bus` into `Textus::Hooks::EventBus` (pubsub) and `Textus::Hooks::RpcRegistry` (named callables). The `Hooks::Bus` constant is removed.
|
|
16
|
+
- Replaced `Textus::Application::Ports` with three capability records: `Textus::Application::ReadCaps`, `WriteCaps`, `HookCaps`.
|
|
17
|
+
- Renamed `Textus::Operations` to `Textus::Session`. Access via `store.session(role:)`. `Operations.for(store, ...)` is removed.
|
|
18
|
+
- Hook RPC callables (`resolve_intake`, `transform_rows`, `validate`) no longer accept `store:` — declare `caps:` (a `WriteCaps` for `resolve_intake`/`validate`, `ReadCaps` for `transform_rows`).
|
|
19
|
+
- Removed all `Manifest` top-level deprecation shims (`zones`, `entries`, `zone_writers`, `permission_for`, etc.). Use `manifest.data.*` / `manifest.policy.*` / `manifest.resolver.*` / `manifest.rules.*`.
|
|
20
|
+
- Moved `Textus::Application::Writes::EnvelopeReader`/`EnvelopeWriter` to `Textus::Application::Envelope::Reader`/`Writer`.
|
|
21
|
+
- Renamed `Textus::Application::Writes` → `Textus::Application::Write`; `Textus::Application::Reads` → `Textus::Application::Read`; `Textus::Application::Restructure` → `Textus::Application::Maintenance`.
|
|
22
|
+
- Merged `Textus::Application::Refresh::*` into `Textus::Application::Write::Refresh{Worker,Orchestrator,All}`.
|
|
23
|
+
- Moved `Textus::Application::Policy::Promotion` and predicates to `Textus::Domain::Policy::Promotion`/`Predicates`.
|
|
24
|
+
|
|
25
|
+
## 0.25.1 — 2026-05-28
|
|
26
|
+
|
|
27
|
+
### Internal refactors
|
|
28
|
+
|
|
29
|
+
- **ADR 0018**: `Manifest` is now a composition record over `Data`,
|
|
30
|
+
`Resolver`, `Policy`, `Rules`. Top-level methods like
|
|
31
|
+
`Manifest#permission_for` are deprecated; use
|
|
32
|
+
`manifest.policy.permission_for(zone)`. One-cycle bridge — shims
|
|
33
|
+
warn until 0.26.0.
|
|
34
|
+
|
|
35
|
+
- **ADR 0016**: Application use cases take a single `ports:` kwarg
|
|
36
|
+
bundling six adapters + the store root. Hook DSL callables that
|
|
37
|
+
declare `|store:|` continue to work with a one-shot deprecation
|
|
38
|
+
warning per (event, hook_name); declare `|ports:|` to silence it.
|
|
39
|
+
|
|
40
|
+
- **ADR 0017**: `Application::Writes::EnvelopeIO` split into
|
|
41
|
+
`EnvelopeReader` (parse) and `EnvelopeWriter` (put/delete/move
|
|
42
|
+
+ audit). Every public `EnvelopeWriter` method now ends with an
|
|
43
|
+
audit-row append — the write-without-audit failure mode is gone.
|
|
44
|
+
|
|
45
|
+
### Breaking (internal)
|
|
46
|
+
|
|
47
|
+
- `Operations#store` accessor removed. There is no clean deprecation
|
|
48
|
+
shim because `Ports` cannot reconstruct a `Store`. External
|
|
49
|
+
callers should use `ops.ports.X` directly.
|
|
50
|
+
|
|
51
|
+
- `Textus::Manifest::Entry::Base::PublishContext` struct shape
|
|
52
|
+
changed: `:store` removed, `:ports` + `:boot` added. Affects
|
|
53
|
+
third-party plugins that build custom derived entries.
|
|
54
|
+
|
|
55
|
+
- `transform_context` passed to `transform_rows` RPC callables is
|
|
56
|
+
now an `Application::Ports`, not a `Store`. Transforms that treat
|
|
57
|
+
it as opaque continue to work; transforms that reach `.x` need
|
|
58
|
+
updates.
|
|
59
|
+
|
|
60
|
+
No CLI verb signatures changed. No wire envelopes changed.
|
|
61
|
+
Protocol remains `textus/3`.
|
|
62
|
+
|
|
63
|
+
## 0.25.0 — 2026-05-28
|
|
64
|
+
|
|
65
|
+
### Added (additive — backward-compatible pulse fields)
|
|
66
|
+
- `pulse.manifest_etag` — sha256 of `manifest.yaml`; lets agents detect contract drift without a second verb.
|
|
67
|
+
- `pulse.next_due_at` — soonest `next_due_at` across all entries with a refresh policy. Schedulers sleep until this timestamp instead of polling.
|
|
68
|
+
- `pulse.hook_errors` — recent hook failures since cursor; bounded in-memory ring on `Hooks::Bus#error_log` (default 256).
|
|
69
|
+
|
|
70
|
+
### Changed
|
|
71
|
+
- `Application::Reads::Freshness` memoizes the evaluator verdict by `(key, last_refreshed_at)` per request — pulse no longer pays O(N) evaluator calls when nothing has changed.
|
|
72
|
+
- `Application::Refresh::Orchestrator` gains a cooperative-cancel fallback for `RefreshTimed` when `fork(2)` is unavailable (Windows). Previously degraded to `Failed("timed_sync requires fork")`; now executes within the budget on a Thread, killing it on budget exceeded.
|
|
73
|
+
|
|
74
|
+
### Protocol
|
|
75
|
+
- No wire-format change. `textus/3` envelopes are unchanged. Pulse fields are additive — existing consumers ignoring unknown keys continue to work.
|
|
76
|
+
|
|
77
|
+
## 0.24.0 — 2026-05-28
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
- **Context-structure ergonomics** (ADR 0015 Phase 2):
|
|
81
|
+
- `textus key mv --prefix OLD NEW` — bulk rename leaves under a prefix; preserves UIDs.
|
|
82
|
+
- `textus key delete --prefix P` — bulk delete leaves.
|
|
83
|
+
- `textus zone mv FROM TO` — rename a zone; refuses if destination exists; rewrites manifest + moves files.
|
|
84
|
+
- `textus rule lint --against=FILE` — diff candidate manifest YAML's `rules:` block against the live manifest.
|
|
85
|
+
- `textus migrate PLAN.yaml` — run a multi-op declarative migration plan (ops: `key_mv_prefix`, `key_delete_prefix`, `zone_mv`).
|
|
86
|
+
- All five operations also surface as MCP tools (`key_mv_prefix`, `key_delete_prefix`, `zone_mv`, `rule_lint`, `migrate`).
|
|
87
|
+
- `Textus::Application::Restructure` module with `Plan` value object and one use case per operation.
|
|
88
|
+
|
|
89
|
+
### Protocol
|
|
90
|
+
- No wire-format change. `textus/3` envelopes are unchanged.
|
|
91
|
+
|
|
92
|
+
## 0.23.0 — 2026-05-28
|
|
93
|
+
|
|
94
|
+
### Added
|
|
95
|
+
- **Agent gate (MCP transport).** `textus mcp serve` — stdio JSON-RPC 2.0
|
|
96
|
+
server speaking MCP draft 2024-11-05. Wraps `Textus::Operations` as ten
|
|
97
|
+
auto-derived tools (`boot`, `tick`, `find`, `read`, `write`, `propose`,
|
|
98
|
+
`refresh`, `refresh_stale`, `schema`, `rules`). Session state (cursor,
|
|
99
|
+
role, manifest_etag) held server-side. Manifest drift surfaces as
|
|
100
|
+
`ContractDrift` (-32001); cursor expiry as `CursorExpired` (-32002).
|
|
101
|
+
See [`docs/mcp.md`](docs/mcp.md) and [ADR 0015](docs/architecture/decisions/0015-agent-gate-mcp.md).
|
|
102
|
+
- `examples/claude-plugin/.mcp.json` and migrated skills/commands/agents —
|
|
103
|
+
zero `textus <verb>` shell strings remain in plugin markdown.
|
|
104
|
+
|
|
105
|
+
### Changed (docs)
|
|
106
|
+
- `ARCHITECTURE.md`: fixed stale `registry` references (now `bus`),
|
|
107
|
+
added Agent Surface section and complete Hooks::Bus event catalog.
|
|
108
|
+
- `docs/agent-integration.md`: documents three transports (CLI, Ruby API,
|
|
109
|
+
MCP); points agent authors at the MCP transport by default.
|
|
110
|
+
|
|
111
|
+
### Protocol
|
|
112
|
+
- No wire-format change. `textus/3` envelopes are unchanged.
|
|
113
|
+
|
|
114
|
+
## 0.22.0 — 2026-05-28
|
|
115
|
+
|
|
116
|
+
### Changed (internal — no manifest-schema impact)
|
|
117
|
+
- **Entry polymorphism pass.** Behavior-preserving refactor that
|
|
118
|
+
consolidates cross-cutting fields on `Manifest::Entry::Base` and
|
|
119
|
+
replaces case-statement dispatch with polymorphic methods. Adding
|
|
120
|
+
a new entry kind now costs ~1 file edit instead of ~5–10.
|
|
121
|
+
- `publish_to` is now owned by `Base` (was declared four separate
|
|
122
|
+
times across Leaf/Derived/Nested/Intake).
|
|
123
|
+
- `Base` exposes nil-returning stubs for `template`, `inject_boot`,
|
|
124
|
+
`events`, `publish_each`, `index_filename` — validators and
|
|
125
|
+
serializers no longer need `respond_to?` guards.
|
|
126
|
+
- `Publish#call` dispatches via `entry.publish_via(context)` instead
|
|
127
|
+
of a 4-branch case-statement. The byte-identical
|
|
128
|
+
`publish_leaf_entry` / `publish_intake_entry` helpers are gone.
|
|
129
|
+
- Each `Entry` subclass declares a `KIND` constant and a
|
|
130
|
+
`self.from_raw(common, raw)` factory; `Parser` dispatches via
|
|
131
|
+
`Entry::REGISTRY` instead of a closed `case kind`.
|
|
132
|
+
- Dead `Base#kind` method removed.
|
|
133
|
+
|
|
134
|
+
No public API or manifest YAML changes. All existing manifests load
|
|
135
|
+
identically.
|
|
136
|
+
|
|
137
|
+
Remaining `is_a?(Entry::Derived)` callsites in `builder/`, `renderer/`,
|
|
138
|
+
`application/reads/`, and `domain/staleness/` are out of scope for this
|
|
139
|
+
pass — they touch a different polymorphism axis (what data the entry
|
|
140
|
+
contributes to a build) and will be addressed in a follow-up.
|
|
141
|
+
|
|
142
|
+
Known follow-up: `Intake#nested?` still reads `@raw["nested"]` to
|
|
143
|
+
preserve the `kind: intake, nested: true` YAML overlay used by nested
|
|
144
|
+
intake handlers. This dual discriminator (`kind:` + `nested:`) is a
|
|
145
|
+
design tension worth revisiting alongside the broader is_a? cleanup.
|
|
146
|
+
|
|
147
|
+
## 0.21.1 — 2026-05-27
|
|
148
|
+
|
|
149
|
+
### Fixed
|
|
150
|
+
- **Intake entries can now act as builder outputs.** Two related gaps closed:
|
|
151
|
+
- `FormatMatrix` validator no longer rejects `kind: intake` entries in
|
|
152
|
+
generator zones for missing a template. Intake bodies come from a
|
|
153
|
+
`:resolve_intake` handler, so the "derived format requires template"
|
|
154
|
+
rule never applied. (Error message widened from "derived #{format}"
|
|
155
|
+
to "#{format} entries in a generator zone require a template".)
|
|
156
|
+
- `Manifest::Entry::Intake` now parses `publish_to:` from YAML (was
|
|
157
|
+
hardcoded to `[]`).
|
|
158
|
+
- `textus publish` / `textus build` now fan out intake bodies to each
|
|
159
|
+
`publish_to` target, mirroring the Leaf fan-out path. Refresh-time
|
|
160
|
+
fan-out is unchanged — bodies still publish on the next publish/build
|
|
161
|
+
run.
|
|
162
|
+
|
|
163
|
+
Closes #80. Lets consumers replace `kind: derived, compute: { kind:
|
|
164
|
+
external }` runner glue with `kind: intake` + `Textus.on(:resolve_intake)`
|
|
165
|
+
hooks for builder-produced outputs.
|
|
166
|
+
|
|
167
|
+
## 0.21.0 — 2026-05-27
|
|
168
|
+
|
|
169
|
+
### BREAKING
|
|
170
|
+
- `textus intro` is removed. Use `textus boot` instead — same envelope, same
|
|
171
|
+
use case, better name (pairs with the new `pulse` verb to form the agent
|
|
172
|
+
lifecycle: `boot` for static contract, `pulse` for dynamic state).
|
|
173
|
+
- The `Textus::Intro` module is now `Textus::Boot`. The manifest entry field
|
|
174
|
+
`inject_intro:` is now `inject_boot:`. Builder template variable
|
|
175
|
+
`{{intro.*}}` is now `{{boot.*}}`. Pre-1.0; no compatibility alias.
|
|
176
|
+
|
|
177
|
+
### Added
|
|
178
|
+
- **`textus pulse [--since=N]`** — agent heartbeat verb. Returns an envelope
|
|
179
|
+
with `cursor` (current `latest_seq`), `changed` (audit rows since N),
|
|
180
|
+
`stale` (entries past refresh policy), `pending_review` (keys in review
|
|
181
|
+
zone), and `doctor` (ok/warn/fail counts). One round-trip replaces what
|
|
182
|
+
was previously four separate verbs.
|
|
183
|
+
- **`agent_quickstart` block in `textus boot`** — names the read verbs,
|
|
184
|
+
write verbs, writable zones, default propose zone, and current
|
|
185
|
+
`latest_seq` (the starting cursor for `pulse`). Lets an agent boot once
|
|
186
|
+
and immediately know how to talk and where to start polling.
|
|
187
|
+
- **Audit log rotation.** Active `audit.log` rotates to `audit.log.1` when
|
|
188
|
+
it exceeds `audit.max_size` (default 10MB), keeping the last
|
|
189
|
+
`audit.keep` files (default 5). Each rotated file has a sidecar
|
|
190
|
+
`audit.log.N.meta.json` with `min_seq`/`max_seq`/`rotated_at`. Configure
|
|
191
|
+
via the new top-level `audit:` block in `manifest.yaml`.
|
|
192
|
+
- **Monotonic `seq` on every audit row.** Foundation for cursor-based
|
|
193
|
+
queries; `audit --seq-since=N` and `pulse --since=N` both use it.
|
|
194
|
+
- **`Textus::CursorExpired`** error class, raised by `pulse` and
|
|
195
|
+
`audit --seq-since` when the requested seq has rotated off disk. The
|
|
196
|
+
message names the oldest still-available seq and tells the agent to
|
|
197
|
+
re-orient via `textus boot`.
|
|
198
|
+
- `docs/agent-integration.md` — boot → pulse → work loop reference, with
|
|
199
|
+
an example agent loop and cursor-expiry handling.
|
|
200
|
+
|
|
201
|
+
### Changed
|
|
202
|
+
- Audit rows now include a `seq` integer field (existing fields unchanged).
|
|
203
|
+
- `textus boot` envelope gains `agent_quickstart` (additive — existing
|
|
204
|
+
consumers unaffected).
|
|
205
|
+
|
|
12
206
|
## 0.20.2 — 2026-05-27
|
|
13
207
|
|
|
14
208
|
### Fixed
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.ruby-lang.org/)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
A context store for codebases that humans and AI agents both have to read and write. Dotted keys, schema-validated entries, role-gated writes, byte-copy publish, an audit log of every change. Built so an agent landing in your repo can run one command (`textus
|
|
8
|
+
A context store for codebases that humans and AI agents both have to read and write. Dotted keys, schema-validated entries, role-gated writes, byte-copy publish, an audit log of every change. Built so an agent landing in your repo can run one command (`textus boot`) and know what to read, what to write, and what's off-limits.
|
|
9
9
|
|
|
10
10
|
Reference implementation in Ruby. Wire format `textus/3`. SPEC: [`SPEC.md`](SPEC.md). Implementation notes: [`docs/`](docs/).
|
|
11
11
|
|
|
@@ -79,11 +79,12 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
|
|
|
79
79
|
|
|
80
80
|
- **Per-entry formats.** `format: markdown | json | yaml | text` on a manifest entry. `cat .textus/zones/output/marketplace.json | jq .` works without going through textus — the in-store file *is* the consumer-shaped artifact. Structured outputs carry `_meta` at the top level (`generated_at`, `from`, `template`, `transform`).
|
|
81
81
|
- **Per-leaf publishing.** Nested entries declare `publish_each: "skills/{basename}/SKILL.md"`. Every leaf byte-copies to its consumer location on `textus build`. No more hand-mirrored `agents/` / `skills/` / `commands/` directories.
|
|
82
|
-
- **
|
|
82
|
+
- **Build and publish in one pass.** `Application::Write::Publish` materializes generator-zone entries and copies nested leaves to their `publish_each` targets. The `textus build` CLI verb dispatches to it; the wire envelope is unchanged.
|
|
83
83
|
- **Typed envelopes (v0.14.0).** `Textus::Envelope` is a `Data.define` value object with typed accessors (`.meta`, `.body`, `.etag`, `.uid`, `.freshness`, …). Ruby API callers get IDE help and `NoMethodError` on typos. The CLI JSON wire format is preserved byte-for-byte via `envelope.to_h_for_wire`.
|
|
84
84
|
- **Stable identity (`uid:`).** 16-char hex, auto-minted on first `put`, preserved across writes and moves. `textus key mv old.key new.key` renames in place — uid survives, audit row records `from_key`, `to_key`, `uid`. Reorganising a tree no longer breaks references.
|
|
85
85
|
- **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus key normalize --dry-run|--write` rewrites existing stores with illegal segments deterministically.
|
|
86
|
-
- **`textus
|
|
86
|
+
- **`textus boot`.** One-shot store orientation: zones with writers + purposes, entry families with schemas and publish targets, loaded hooks, write flows per role, the full CLI verb table, and an `agent_quickstart` block (read/write verbs, writable zones, propose zone, latest audit seq). The boot signal for any agent — one tool call and it knows your store.
|
|
87
|
+
- **`textus pulse [--since=N]`.** Per-turn heartbeat for agents: changed entries since cursor N, stale keys, pending review proposals, and a doctor summary. Cursor is a monotonic seq stamped on every audit row; rotation keeps the last 5 files (configurable via `audit:` in the manifest) and raises `CursorExpired` when the requested cursor has fallen off disk.
|
|
87
88
|
- **`textus doctor`.** Health check across 9 categories: missing schemas/templates, broken hooks, illegal nested keys, sentinel drift, audit log readability, unowned schema fields, schema violations, and missing manifest files. Returns `ok: true` only when nothing is wrong; warnings and info don't flip the bit.
|
|
88
89
|
- **Actionable hints on every error.** `UnknownKey` carries ranked "did you mean" suggestions. `WriteForbidden` names the role that *would* be allowed. `BadFrontmatter` tells you exactly what to rename. Printed to stderr alongside the JSON envelope on stdout.
|
|
89
90
|
- **Compute.** Derived entries declare `compute: { kind: projection, ... }` (declarative rows + template) or `compute: { kind: external, ... }` (build runner produces the file; textus tracks sources for staleness). Inside projection computes, `transform:` names the row-shaping hook.
|
|
@@ -97,7 +98,7 @@ All verbs accept `--output=json` and return the envelope defined in [SPEC §8](S
|
|
|
97
98
|
- Full verb table — read, write, health, scaffolding — is in [SPEC §9](SPEC.md).
|
|
98
99
|
- Zone semantics and the role/`write_policy` mapping live in [SPEC §5](SPEC.md), with a tutorial expansion in [`docs/zones.md`](docs/zones.md).
|
|
99
100
|
|
|
100
|
-
`textus
|
|
101
|
+
`textus boot` prints the same information for the current store: zones, entry families with schemas, registered hooks, write flows, and the verb catalog. Run it inside a store and you get the live picture; reach for the SPEC when you want the contract.
|
|
101
102
|
|
|
102
103
|
## Compute and publish
|
|
103
104
|
|
|
@@ -150,9 +151,11 @@ See SPEC.md §5.10 for the full hook contract.
|
|
|
150
151
|
|
|
151
152
|
Schemas (`.textus/schemas/<name>.yaml`) declare field shapes, per-field `maintained_by:` ownership, and an `evolution:` block (`added_in`, `deprecated_at`, `migrate_from`). Full contract in SPEC §5.8.
|
|
152
153
|
|
|
154
|
+
See [`docs/agent-integration.md`](docs/agent-integration.md) for the agent boot → pulse loop.
|
|
155
|
+
|
|
153
156
|
## Examples
|
|
154
157
|
|
|
155
|
-
[`examples/claude-plugin/`](examples/claude-plugin/) — a Claude Code plugin (`voice-tools`) whose entire content surface — agents, skills, commands, `CLAUDE.md`, `plugin.json`, `marketplace.json` — is textus-managed. Demonstrates per-entry formats, `publish_each`, intake actions, in-process transforms and hooks, the agent-propose / human-accept loop, and the `
|
|
158
|
+
[`examples/claude-plugin/`](examples/claude-plugin/) — a Claude Code plugin (`voice-tools`) whose entire content surface — agents, skills, commands, `CLAUDE.md`, `plugin.json`, `marketplace.json` — is textus-managed. Demonstrates per-entry formats, `publish_each`, intake actions, in-process transforms and hooks, the agent-propose / human-accept loop, and the `inject_boot:` flag that puts an orientation preamble at the top of `CLAUDE.md`.
|
|
156
159
|
|
|
157
160
|
## Tests
|
|
158
161
|
|
data/SPEC.md
CHANGED
|
@@ -189,7 +189,7 @@ Validation at manifest load: any unknown variable raises `UsageError`; the templ
|
|
|
189
189
|
|
|
190
190
|
A leaf at `working.skills.writing.voice-writer` (authored at `.textus/zones/working/skills/writing/voice-writer.md`) publishes to `skills/voice-writer/SKILL.md`.
|
|
191
191
|
|
|
192
|
-
**`
|
|
192
|
+
**`inject_boot:`.** A derived entry with a `template:` MAY declare `inject_boot: true`. When the builder materializes the entry, it merges the `textus boot` envelope (§9) into the projection data under the key `boot`, so the template can render orientation content (zones, write flows, CLI catalog) alongside its projected rows. The flag is rejected at manifest load on (a) non-derived entries or (b) derived entries without a `template:` — agents reading the rendered file should be able to trust the preamble was produced by the same source of truth `textus boot` exposes.
|
|
193
193
|
|
|
194
194
|
**Lookup rule:** to resolve a key, find the entry with the longest `key:` prefix that matches. If that entry has `nested: true`, the remaining segments map to subdirectories under its `path`. Otherwise the key must equal an entry exactly. The resolved filesystem path is `<.textus root>/zones/<entry.path>[/<remaining>...].md` — implementations MUST prepend `zones/` to the manifest `path:` when constructing the filesystem location.
|
|
195
195
|
|
|
@@ -430,9 +430,11 @@ Every successful write appends one compact JSON object (NDJSON) to `.textus/audi
|
|
|
430
430
|
Schema (one JSON object per line, no interior whitespace):
|
|
431
431
|
|
|
432
432
|
```json
|
|
433
|
-
{"ts":"<iso8601-utc>","role":"<role>","verb":"<verb>","key":"<key>","etag_before":<etag-or-null>,"etag_after":<etag-or-null>}
|
|
433
|
+
{"seq":<integer>,"ts":"<iso8601-utc>","role":"<role>","verb":"<verb>","key":"<key>","etag_before":<etag-or-null>,"etag_after":<etag-or-null>}
|
|
434
434
|
```
|
|
435
435
|
|
|
436
|
+
`seq` is a monotonic integer counter, auto-incremented on each append. It is the foundation for cursor-based queries: `textus audit --seq-since=N` returns only rows with `seq > N`, and `textus pulse --since=N` builds its `changed` array from the same cursor. When an agent's cursor falls below the oldest available seq (due to log rotation), the operation raises `CursorExpired`.
|
|
437
|
+
|
|
436
438
|
`ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `delete`, `accept`, `compute`, `mv`, ...). `key` is the affected entry key. `etag_before` and `etag_after` are the entry etags before and after the write, or JSON `null` when not applicable (e.g. create has no before-etag, delete has no after-etag).
|
|
437
439
|
|
|
438
440
|
For `mv`, the structural fields `from_key`, `to_key`, and `uid` appear at the top level of the JSON object. Remaining verb-specific data (e.g. `from_path`, `to_path`) is nested under an `extras` key. The `extras` key is omitted entirely when empty.
|
|
@@ -729,7 +731,8 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
729
731
|
| `hook list` | read | any |
|
|
730
732
|
| `hook run NAME` | write | any |
|
|
731
733
|
| `doctor [--check=NAME[,NAME]] [--output=json]` | read | any |
|
|
732
|
-
| `
|
|
734
|
+
| `boot [--output=json]` | read | any |
|
|
735
|
+
| `pulse [--since=N]` | read | any |
|
|
733
736
|
| `put K --stdin --as=R [--fetch=NAME]` | write | per zone |
|
|
734
737
|
| `delete K --if-etag=E --as=R` | write | per zone |
|
|
735
738
|
| `refresh KEY --as=runner` | write | per zone (typically `runner`) |
|
|
@@ -741,6 +744,36 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
741
744
|
| `key mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
|
|
742
745
|
| `key uid K` | read | any |
|
|
743
746
|
|
|
747
|
+
**`textus boot` envelope extras.** In addition to zones, entries, hooks, write flows, and the `cli_verbs` catalog, the boot envelope includes an `agent_quickstart` block synthesized from the manifest's role-kind declarations:
|
|
748
|
+
|
|
749
|
+
```json
|
|
750
|
+
{
|
|
751
|
+
"agent_quickstart": {
|
|
752
|
+
"read_verbs": ["boot", "get", "list", "audit", "pulse", "freshness", "doctor"],
|
|
753
|
+
"write_verbs": ["put KEY --as=<proposer-role> --stdin"],
|
|
754
|
+
"writable_zones": ["review"],
|
|
755
|
+
"propose_zone": "review",
|
|
756
|
+
"latest_seq": 1842
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
`latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
|
|
762
|
+
|
|
763
|
+
**`textus pulse` output shape:**
|
|
764
|
+
|
|
765
|
+
```json
|
|
766
|
+
{
|
|
767
|
+
"cursor": 1845,
|
|
768
|
+
"changed": [ { "seq": 1843, "key": "working.x", "verb": "put", "role": "human", "ts": "..." } ],
|
|
769
|
+
"stale": [ "output.marketplace" ],
|
|
770
|
+
"pending_review": [ "review.proposal.123" ],
|
|
771
|
+
"doctor": { "ok": true, "warn": 0, "fail": 0 }
|
|
772
|
+
}
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
`cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `stale` is sourced from `freshness`. `pending_review` lists all keys in the review zone. `doctor` is an `{ok, warn, fail}` count summary. When `--since` is below the oldest available seq (due to audit log rotation), pulse returns `CursorExpired`.
|
|
776
|
+
|
|
744
777
|
**`put` input** (read from stdin when `--stdin` is given):
|
|
745
778
|
|
|
746
779
|
```json
|
|
@@ -798,6 +831,12 @@ Every `Textus::Error` exposes `code`, `message`, and an optional `hint:`. The hi
|
|
|
798
831
|
|
|
799
832
|
The reference Ruby gem follows semver independently and speaks `textus/3`.
|
|
800
833
|
|
|
834
|
+
## 11.1 Agent integration
|
|
835
|
+
|
|
836
|
+
Agents interact with a textus store through two verbs: `boot` (once per session, for orientation) and `pulse` (per turn, for deltas). The `boot` envelope's `agent_quickstart` block gives the agent its starting cursor (`latest_seq`), its writable zones, and its propose zone. The `pulse` verb returns a delta envelope keyed on that cursor. When audit log rotation expires a cursor, `CursorExpired` signals the agent to call `boot` again.
|
|
837
|
+
|
|
838
|
+
For the full boot → pulse loop with pseudocode and cursor-expiry handling, see [`docs/agent-integration.md`](docs/agent-integration.md).
|
|
839
|
+
|
|
801
840
|
## 12. Conformance fixtures
|
|
802
841
|
|
|
803
842
|
A conformant implementation MUST pass these fixtures (the reference test suite ships a YAML file listing inputs and expected envelopes):
|
|
@@ -849,10 +888,10 @@ Given a review entry `review.identity.self.patch` proposing a change to `identit
|
|
|
849
888
|
|
|
850
889
|
Textus internals are organized into four layers. The dependency rule is one-way — each layer may only import from the layer beneath it.
|
|
851
890
|
|
|
852
|
-
- **Interface** (`lib/textus/cli/`) — CLI verbs. Parses flags, calls a use case, formats JSON.
|
|
853
|
-
- **Application** (`lib/textus/application/`) — Use cases: `
|
|
854
|
-
- **Domain** (`lib/textus/domain/`) — Pure values: `Freshness::Policy`, `Action`, `Outcome`, `
|
|
855
|
-
- **Infrastructure** (`lib/textus/infra/`) — Adapters: `
|
|
891
|
+
- **Interface** (`lib/textus/cli/`, `lib/textus/mcp/`) — CLI verbs and the MCP gate. Parses flags / RPC, calls a use case, formats JSON.
|
|
892
|
+
- **Application** (`lib/textus/application/`) — Use cases: `Read::Get`, `Write::Put`, `Write::RefreshWorker`, `Write::RefreshOrchestrator`, `Write::RefreshAll`, `Maintenance::Migrate`, etc. Orchestrate domain + infra; no business rules.
|
|
893
|
+
- **Domain** (`lib/textus/domain/`) — Pure values: `Authorizer`, `Permission`, `Freshness::{Policy,Verdict,Evaluator}`, `Action`, `Outcome`, `Sentinel`, `Staleness`. No I/O, no globals, testable without disk.
|
|
894
|
+
- **Infrastructure** (`lib/textus/infra/`) — Adapters: `Storage::FileStore`, `AuditLog`, `AuditSubscriber`, `Publisher`, `Clock`, `Refresh::Lock`, `Refresh::Detached`, `BuildLock`. Wrap OS / library primitives.
|
|
856
895
|
|
|
857
896
|
The `lib/textus/store/`, `lib/textus/manifest/`, `lib/textus/hooks/` namespaces are infrastructure adapters that predate this split and remain at their existing paths for backward-compat with the plugin DSL.
|
|
858
897
|
|
|
@@ -860,14 +899,14 @@ Plugin authors interact only with the Hook DSL (`Textus.hook { |reg| reg.on(:res
|
|
|
860
899
|
|
|
861
900
|
Both read and write paths flow through the application layer:
|
|
862
901
|
|
|
863
|
-
- **Reads** flow through `Application::
|
|
864
|
-
- **Writes** flow through `Application::
|
|
865
|
-
- `Application::Context` is the
|
|
866
|
-
- `Textus::
|
|
867
|
-
use
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
`
|
|
902
|
+
- **Reads** flow through `Application::Read::Get` (pure read + freshness annotation) or `Read::GetOrRefresh` (composes Get with `Write::RefreshOrchestrator`). Each takes a `caps:` slice and an `Application::Context`.
|
|
903
|
+
- **Writes** flow through `Application::Write::{Put,Delete,Mv,Accept,Reject,Publish,RefreshWorker}`. Permission checks happen at the use-case layer (via `Domain::Authorizer#authorize_write!`); the audit-append invariant lives in `Application::Envelope::Writer`.
|
|
904
|
+
- `Application::Context` is the slim request record: `role`, `correlation_id`, `now`, `dry_run`. Ports come from a `Caps` record (Read/Write/Hook), not from the Context.
|
|
905
|
+
- `Textus::Session` is the factory CLI verbs and the MCP gate use to dispatch
|
|
906
|
+
use cases. `Session.for(store, role:)` returns a per-call object exposing one
|
|
907
|
+
method per registered use case (`#put`, `#get`, `#refresh`, …); methods are
|
|
908
|
+
generated from `Application::UseCase.entries` so adding a use case is a
|
|
909
|
+
single `UseCase.register(...)` line.
|
|
871
910
|
|
|
872
911
|
See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
|
|
873
912
|
|