textus 0.26.0 → 0.30.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 +118 -68
- data/CHANGELOG.md +132 -0
- data/README.md +61 -19
- data/SPEC.md +107 -46
- data/docs/conventions.md +4 -4
- data/lib/textus/boot.rb +18 -12
- data/lib/textus/builder/pipeline.rb +13 -12
- data/lib/textus/call.rb +28 -0
- data/lib/textus/cli/verb/audit.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +1 -1
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +2 -6
- data/lib/textus/cli/verb/put.rb +5 -14
- data/lib/textus/cli/verb/retain.rb +19 -0
- data/lib/textus/cli/verb/rule_list.rb +1 -1
- data/lib/textus/cli/verb.rb +6 -6
- data/lib/textus/cli.rb +19 -23
- data/lib/textus/container.rb +23 -0
- data/lib/textus/dispatcher.rb +57 -0
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +10 -8
- data/lib/textus/doctor/check.rb +15 -5
- data/lib/textus/doctor.rb +7 -7
- data/lib/textus/domain/authorizer.rb +2 -2
- data/lib/textus/domain/duration.rb +22 -0
- data/lib/textus/domain/policy/refresh.rb +1 -15
- data/lib/textus/domain/policy/retention.rb +26 -0
- data/lib/textus/domain/retention.rb +44 -0
- data/lib/textus/domain/sentinel.rb +9 -65
- data/lib/textus/domain/staleness/generator_check.rb +46 -26
- data/lib/textus/domain/staleness/intake_check.rb +18 -10
- data/lib/textus/domain/staleness.rb +3 -3
- data/lib/textus/{application/envelope → envelope/io}/reader.rb +6 -2
- data/lib/textus/{application/envelope → envelope/io}/writer.rb +19 -11
- data/lib/textus/hooks/context.rb +30 -13
- data/lib/textus/hooks/event_bus.rb +8 -20
- data/lib/textus/hooks/rpc_registry.rb +9 -35
- data/lib/textus/hooks/signature.rb +31 -0
- data/lib/textus/init.rb +7 -6
- data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
- data/lib/textus/maintenance/migrate.rb +51 -0
- data/lib/textus/maintenance/rule_lint.rb +56 -0
- data/lib/textus/maintenance/zone_mv.rb +51 -0
- data/lib/textus/maintenance.rb +15 -0
- data/lib/textus/manifest/data.rb +9 -4
- data/lib/textus/manifest/entry/base.rb +38 -18
- data/lib/textus/manifest/entry/derived.rb +6 -6
- data/lib/textus/manifest/entry/nested.rb +7 -9
- data/lib/textus/manifest/entry/parser.rb +2 -2
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
- data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +2 -2
- data/lib/textus/manifest/entry.rb +0 -5
- data/lib/textus/manifest/policy.rb +34 -7
- data/lib/textus/manifest/rules.rb +10 -1
- data/lib/textus/manifest/schema.rb +54 -4
- data/lib/textus/manifest.rb +4 -8
- data/lib/textus/mcp/server.rb +2 -11
- data/lib/textus/mcp/session.rb +13 -20
- data/lib/textus/mcp/tools.rb +2 -2
- data/lib/textus/mcp.rb +1 -1
- data/lib/textus/{infra → ports}/audit_log.rb +1 -1
- data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
- data/lib/textus/{infra → ports}/build_lock.rb +1 -1
- data/lib/textus/{infra → ports}/clock.rb +1 -1
- data/lib/textus/{infra → ports}/publisher.rb +6 -6
- data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
- data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
- data/lib/textus/ports/sentinel_store.rb +67 -0
- data/lib/textus/ports/storage/file_stat.rb +19 -0
- data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
- data/lib/textus/projection.rb +91 -0
- data/lib/textus/read/audit.rb +111 -0
- data/lib/textus/read/blame.rb +81 -0
- data/lib/textus/read/boot.rb +18 -0
- data/lib/textus/read/deps.rb +24 -0
- data/lib/textus/read/doctor.rb +19 -0
- data/lib/textus/read/freshness.rb +101 -0
- data/lib/textus/read/get.rb +66 -0
- data/lib/textus/read/get_or_refresh.rb +69 -0
- data/lib/textus/read/list.rb +15 -0
- data/lib/textus/read/policy_explain.rb +42 -0
- data/lib/textus/read/published.rb +15 -0
- data/lib/textus/read/pulse.rb +89 -0
- data/lib/textus/read/rdeps.rb +25 -0
- data/lib/textus/read/retainable.rb +17 -0
- data/lib/textus/read/schema_envelope.rb +16 -0
- data/lib/textus/read/stale.rb +17 -0
- data/lib/textus/read/uid.rb +20 -0
- data/lib/textus/read/validate_all.rb +22 -0
- data/lib/textus/read/validator.rb +84 -0
- data/lib/textus/read/where.rb +16 -0
- data/lib/textus/role_scope.rb +50 -0
- data/lib/textus/schema/tools.rb +3 -3
- data/lib/textus/store.rb +16 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +86 -0
- data/lib/textus/write/authority_gate.rb +24 -0
- data/lib/textus/write/delete.rb +40 -0
- data/lib/textus/write/intake_fetch.rb +23 -0
- data/lib/textus/write/materializer.rb +48 -0
- data/lib/textus/write/mv.rb +113 -0
- data/lib/textus/write/publish.rb +66 -0
- data/lib/textus/write/put.rb +45 -0
- data/lib/textus/write/refresh_all.rb +44 -0
- data/lib/textus/write/refresh_orchestrator.rb +102 -0
- data/lib/textus/write/refresh_worker.rb +124 -0
- data/lib/textus/write/reject.rb +54 -0
- data/lib/textus/write/retention_sweep.rb +55 -0
- data/lib/textus.rb +1 -2
- metadata +62 -50
- data/lib/textus/application/caps.rb +0 -49
- data/lib/textus/application/context.rb +0 -34
- data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
- data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
- data/lib/textus/application/maintenance/migrate.rb +0 -59
- data/lib/textus/application/maintenance/rule_lint.rb +0 -65
- data/lib/textus/application/maintenance/zone_mv.rb +0 -60
- data/lib/textus/application/maintenance.rb +0 -17
- data/lib/textus/application/projection.rb +0 -93
- data/lib/textus/application/read/audit.rb +0 -106
- data/lib/textus/application/read/blame.rb +0 -91
- data/lib/textus/application/read/deps.rb +0 -34
- data/lib/textus/application/read/freshness.rb +0 -110
- data/lib/textus/application/read/get.rb +0 -75
- data/lib/textus/application/read/get_or_refresh.rb +0 -63
- data/lib/textus/application/read/list.rb +0 -25
- data/lib/textus/application/read/policy_explain.rb +0 -47
- data/lib/textus/application/read/published.rb +0 -25
- data/lib/textus/application/read/pulse.rb +0 -101
- data/lib/textus/application/read/rdeps.rb +0 -35
- data/lib/textus/application/read/schema_envelope.rb +0 -26
- data/lib/textus/application/read/stale.rb +0 -23
- data/lib/textus/application/read/uid.rb +0 -30
- data/lib/textus/application/read/validate_all.rb +0 -32
- data/lib/textus/application/read/validator.rb +0 -86
- data/lib/textus/application/read/where.rb +0 -26
- data/lib/textus/application/use_case.rb +0 -22
- data/lib/textus/application/write/accept.rb +0 -102
- data/lib/textus/application/write/authority_gate.rb +0 -26
- data/lib/textus/application/write/delete.rb +0 -45
- data/lib/textus/application/write/materializer.rb +0 -49
- data/lib/textus/application/write/mv.rb +0 -118
- data/lib/textus/application/write/publish.rb +0 -96
- data/lib/textus/application/write/put.rb +0 -49
- data/lib/textus/application/write/refresh_all.rb +0 -63
- data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
- data/lib/textus/application/write/refresh_worker.rb +0 -134
- data/lib/textus/application/write/reject.rb +0 -62
- data/lib/textus/session.rb +0 -84
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d84508fac499044df50d5fa793000fbd2249732c6b867f1b0f88535bfab4f083
|
|
4
|
+
data.tar.gz: 5069218d7c3c1a8360f4507bb99f94951e92a7d345ba4fb44ee72819fb4739e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: abf8e5e74b77227f34bbc95dd38c1e9b8d716f56f1cda11664f3da284f417d8581a51e4c27accdcb88173c274ff1aa929443bb027b851a95cbced029fdf34852
|
|
7
|
+
data.tar.gz: bd63c95ae46643c063c1b035fc859957418f185795fc32b54f798b44779d825101d9f71667a85f518dacab6ffbcebe70bc5676c9ae24e80cc9de4fa44e38aa38
|
data/ARCHITECTURE.md
CHANGED
|
@@ -2,31 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
```
|
|
4
4
|
┌─ Interface ────────────────────────────────────────────────┐
|
|
5
|
-
│ CLI verbs:
|
|
6
|
-
│
|
|
7
|
-
│ #
|
|
8
|
-
│ # (put/get/refresh/…) │
|
|
5
|
+
│ CLI verbs: store.<verb>(..., role:) │
|
|
6
|
+
│ store.as(role).<verb>(...) │
|
|
7
|
+
│ # (put/get/refresh/…) │
|
|
9
8
|
│ │
|
|
10
9
|
│ MCP gate: textus mcp serve — same use cases, JSON-RPC. │
|
|
11
10
|
└──────────────────────┬─────────────────────────────────────┘
|
|
12
11
|
│
|
|
13
12
|
┌─ Application ────────▼─────────────────────────────────────┐
|
|
14
|
-
│
|
|
13
|
+
│ Call (slim Data: role, correlation_id, now, │
|
|
15
14
|
│ dry_run — request state only) │
|
|
16
|
-
│
|
|
17
|
-
│
|
|
18
|
-
│
|
|
19
|
-
│ UseCase (registry: verb → module, caps_kind) │
|
|
15
|
+
│ Container (single record — wired ports + manifest) │
|
|
16
|
+
│ Dispatcher (static VERBS table: verb → use-case) │
|
|
17
|
+
│ RoleScope (Store#as(role) — forwards verb calls) │
|
|
20
18
|
│ │
|
|
21
19
|
│ read/{get,get_or_refresh,list,where,uid,schema_envelope, │
|
|
22
|
-
│ deps,rdeps,published,stale,validate_all,
|
|
20
|
+
│ deps,rdeps,published,stale,validate_all,boot,doctor,│
|
|
23
21
|
│ freshness,audit,blame,policy_explain,pulse}.rb │
|
|
24
22
|
│ write/{put,delete,mv,accept,reject,publish, │
|
|
25
23
|
│ materializer,authority_gate, │
|
|
26
24
|
│ refresh_worker,refresh_orchestrator,refresh_all} │
|
|
27
25
|
│ maintenance/{migrate,key_mv_prefix,key_delete_prefix, │
|
|
28
26
|
│ zone_mv,rule_lint}.rb │
|
|
29
|
-
│ envelope/{reader,writer}.rb (split: parse vs persist)
|
|
27
|
+
│ envelope/io/{reader,writer}.rb (split: parse vs persist) │
|
|
30
28
|
│ projection.rb │
|
|
31
29
|
└──────────┬───────────────────────────────┬─────────────────┘
|
|
32
30
|
│ uses domain │ uses ports
|
|
@@ -42,115 +40,165 @@
|
|
|
42
40
|
│ implements
|
|
43
41
|
┌─ Infrastructure ─────────────────────────▼─────────────────┐
|
|
44
42
|
│ Store (composition root — wires ports, │
|
|
45
|
-
│ vends
|
|
43
|
+
│ vends a Container + dispatches verbs) │
|
|
46
44
|
│ Storage::FileStore (bytes-only port: read/write/delete/ │
|
|
47
45
|
│ exists?/etag) │
|
|
48
46
|
│ Manifest (Data, Resolver, Policy, Rules) │
|
|
49
47
|
│ Schemas (eager-load cache) │
|
|
50
|
-
│
|
|
48
|
+
│ Ports::{AuditLog,AuditSubscriber,Publisher,Clock, │
|
|
51
49
|
│ Refresh::Lock,Refresh::Detached,BuildLock} │
|
|
52
50
|
│ Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport, │
|
|
53
|
-
│ Builtin,ErrorLog}
|
|
51
|
+
│ Signature,Builtin,ErrorLog} │
|
|
54
52
|
│ Entry::{Markdown,Json,Yaml,Text} (format strategies) │
|
|
55
53
|
└────────────────────────────────────────────────────────────┘
|
|
56
54
|
|
|
57
|
-
Dependency rule: arrows point DOWN. Domain
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
Dependency rule: arrows point DOWN. Domain performs no direct
|
|
56
|
+
File/Dir/Time.now I/O — all disk and clock access is routed through
|
|
57
|
+
injected ports (FileStat, Clock). Pure path math (File.join/dirname/
|
|
58
|
+
absolute_path?/expand_path/basename), Digest hashing of injected
|
|
59
|
+
bytes, and Time.parse of stored strings are NOT I/O and are allowed.
|
|
60
|
+
Application imports Domain + Ports.
|
|
61
|
+
Use cases are plain classes on (container:, call:).
|
|
62
|
+
Verbs are looked up in the static Dispatcher::VERBS table.
|
|
61
63
|
```
|
|
62
64
|
|
|
63
65
|
## How a verb becomes a method
|
|
64
66
|
|
|
65
|
-
Each application use case is a
|
|
67
|
+
Each application use case is a plain class under `lib/textus/{read,write,maintenance}/`. The shape is uniform:
|
|
66
68
|
|
|
67
69
|
```ruby
|
|
68
70
|
module Textus
|
|
69
|
-
module
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
@ctx = ctx; @manifest = caps.manifest; ...
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def call(key) ... end
|
|
82
|
-
end
|
|
71
|
+
module Read
|
|
72
|
+
class Get
|
|
73
|
+
def initialize(container:, call:)
|
|
74
|
+
@container = container
|
|
75
|
+
@call = call
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def call(key)
|
|
79
|
+
...
|
|
83
80
|
end
|
|
84
81
|
end
|
|
85
82
|
end
|
|
86
83
|
end
|
|
87
|
-
|
|
88
|
-
Textus::Application::UseCase.register(:get, Textus::Application::Read::Get, caps: :read)
|
|
89
84
|
```
|
|
90
85
|
|
|
91
|
-
`
|
|
86
|
+
Verbs are looked up in a static frozen table (`Textus::Dispatcher::VERBS`) that maps `:get → Textus::Read::Get`, `:put → Textus::Write::Put`, etc. `Store#put` / `Store#get` / `Store#as(role).<verb>(...)` instantiate the use case on `(container:, call:)` and invoke `#call`. Adding a new verb is **one entry in `Dispatcher::VERBS`** plus the class — no metaprogramming.
|
|
92
87
|
|
|
93
|
-
|
|
88
|
+
The instantiate-and-call step itself has one home: `Dispatcher.invoke(verb, container:, call:, args:, kwargs:)` (ADR 0026). `RoleScope` builds the `Call` (request state) and delegates the dispatch to `Dispatcher.invoke`; the convention for invoking a uniform-shape use case lives next to the table that maps the verbs, not re-spelled in the caller. `Store`'s own verb loop is separate — it extracts the `role:` keyword and forwards to `as(role)`, a role-selection job distinct from invocation.
|
|
94
89
|
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
`boot` and `doctor` are read verbs like any other: `Read::Boot` / `Read::Doctor`
|
|
91
|
+
are thin `(container:, call:)` use cases that delegate to the `Textus::Boot` /
|
|
92
|
+
`Textus::Doctor` report-builder libraries (`build(container:, ...)`). They are
|
|
93
|
+
reached through `Dispatcher::VERBS`, not a special method on `RoleScope`.
|
|
97
94
|
|
|
98
|
-
|
|
95
|
+
Two collaborators live outside the dispatcher because they're composed by other use cases, not invoked as verbs:
|
|
99
96
|
|
|
100
|
-
|
|
97
|
+
- `Write::RefreshOrchestrator` — composes `RefreshWorker` with the freshness `Action` returned by `Domain::Freshness`.
|
|
98
|
+
- `Envelope::IO::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`.
|
|
99
|
+
|
|
100
|
+
## Container
|
|
101
|
+
|
|
102
|
+
Use cases never see the raw `Store`. `Textus::Container` is a single record holding the wired collaborators:
|
|
101
103
|
|
|
102
104
|
```ruby
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
Container = Data.define(
|
|
106
|
+
:manifest, :file_store, :schemas, :root,
|
|
107
|
+
:audit_log, :events, :rpc, :authorizer
|
|
108
|
+
)
|
|
106
109
|
```
|
|
107
110
|
|
|
108
|
-
|
|
111
|
+
The `Store` builds one `Container` at boot; every use case receives it via `(container:, call:)`. RPC hook callables (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps: <Container>` — field names match what the prior `WriteCaps` exposed, so handlers reading `caps.manifest`, `caps.events`, etc. continue to work.
|
|
112
|
+
|
|
113
|
+
## Ports
|
|
114
|
+
|
|
115
|
+
Ports are infrastructure adapters with an interface defined by the domain. Each port is independently replaceable — swap the implementation for tests or alternative runtimes without touching application or domain code.
|
|
116
|
+
|
|
117
|
+
| Class | Role |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `Ports::Storage::FileStore` | Bytes-only FS I/O — `read`, `write`, `delete`, `exists?`, `etag`. No knowledge of envelopes or schemas. |
|
|
120
|
+
| `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking, and rotation. |
|
|
121
|
+
| `Ports::Clock` | Supplies `Time.now` — a module-function so tests can swap it without dependency injection boilerplate. |
|
|
122
|
+
| `Ports::Publisher` | Copies a built artifact to a repo-relative consumer path and writes a sentinel so the next publish can confirm the target is managed. |
|
|
123
|
+
| `Ports::Refresh::Lock` | Non-blocking `flock`-backed lock per key — prevents concurrent refresh workers from racing on the same entry. |
|
|
124
|
+
| `Ports::Refresh::Detached` | Spawns a background thread for async refresh; the caller receives a `refresh_backgrounded` event instead of blocking. |
|
|
125
|
+
| `Ports::BuildLock` | Process-exclusive `flock` guard over the materializer build pipeline. Raises `BuildInProgress` if a build is already running. |
|
|
126
|
+
|
|
127
|
+
Application use cases access ports only through `Container` fields — never through the raw `Store`.
|
|
128
|
+
|
|
129
|
+
### EnvelopeIO
|
|
130
|
+
|
|
131
|
+
`Envelope::IO::Reader` and `Envelope::IO::Writer` split the envelope pipeline into read-only parse and write-with-audit halves.
|
|
109
132
|
|
|
110
|
-
|
|
133
|
+
**Reader** (`lib/textus/envelope/io/reader.rb`) — resolves a key through `manifest.resolver`, reads bytes via `FileStore`, delegates parsing to the format strategy (`Entry.for_format`), and returns an `Envelope`. No audit, no events, no permission checks. Also used by `Writer` for the existing-uid lookup on `put`.
|
|
111
134
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
135
|
+
**Writer** (`lib/textus/envelope/io/writer.rb`) — owns the full write pipeline: serialize → schema-validate → etag-check → `FileStore#write` → `AuditLog#append`. The class comment states the invariant directly: every public method's final action is `@audit_log.append(...)`. If the audit append fails, the caller sees the underlying error — the byte write already happened, but the pipeline contract treats audit as the commit step. No permission check, no event firing — those stay in the calling use case (`Write::Put`, `Write::Delete`, `Write::Mv`).
|
|
136
|
+
|
|
137
|
+
The three public methods are `put`, `delete`, and `move`; all follow the same validate → write → audit sequence.
|
|
138
|
+
|
|
139
|
+
Both are built from a `Container` via named constructors — `Writer.from(container:, call:)` (which builds its own `Reader.from`) and `Reader.from(container:)` (ADR 0026). Write use cases call `Writer.from` rather than reconstructing the object graph by hand, so a change to the Writer's dependencies is a one-line edit in one place.
|
|
140
|
+
|
|
141
|
+
## Manifest carving
|
|
142
|
+
|
|
143
|
+
Manifest carving means slicing the parsed manifest YAML into four purpose-specific sub-objects. Each consumer sees only the fields it needs; none reach into the full raw document.
|
|
144
|
+
|
|
145
|
+
`Manifest` itself is a `Data.define` struct — a composition record with four named members:
|
|
146
|
+
|
|
147
|
+
| Member | Class | Responsibility |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `data` | `Manifest::Data` | Frozen value: `raw`, `root`, `zones`, `entries`, `audit_config`, `role_mapping`. Structural data only — no behaviour beyond accessors and key validation. |
|
|
150
|
+
| `resolver` | `Manifest::Resolver` | Key → `Resolution(entry, path, remaining)`. Handles nested entry enumeration and fuzzy-match suggestions. |
|
|
151
|
+
| `policy` | `Manifest::Policy` | Zone/role authority — `zone_writers`, `zone_kinds`, `permission_for`, `role_kind`, `roles_with_kind`, `propose_zone_for(role)`. Derived from a `Data` snapshot; no filesystem I/O. `propose_zone_for` owns the "first writable zone whose name contains `review`" convention used by `MCP::Server` (ADR 0027). |
|
|
152
|
+
| `rules` | `Manifest::Rules` | Pattern-matched rule engine. `rules.for(key)` returns a `RuleSet(refresh, handler_allowlist, promote, retention)` by evaluating all `match:` blocks against the key. |
|
|
153
|
+
|
|
154
|
+
Rationale: cleaner test seams — a use case that only needs key resolution constructs a `Manifest::Resolver` from a stub `Data`; one that only needs rule lookup constructs a `Manifest::Rules` directly. No consumer is forced to build the full manifest to exercise one sub-view.
|
|
155
|
+
|
|
156
|
+
The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Manifest::Data` constructs `Policy` internally during `initialize`; the others are assembled by the loader and handed in as named arguments.
|
|
157
|
+
|
|
158
|
+
## Read path (`store.get(key)`)
|
|
159
|
+
|
|
160
|
+
1. CLI verb (or MCP tool) calls `store.get(key, role:)` (or `store.as(role).get(key)`).
|
|
161
|
+
2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`.
|
|
162
|
+
3. `Read::Get#call` resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope.
|
|
163
|
+
4. Looks up the refresh policy via `container.manifest.rules.for(key)`. If absent, returns the envelope annotated fresh.
|
|
116
164
|
5. Otherwise `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`; the envelope is annotated with `stale`, `reason`, `refreshing: false`.
|
|
117
165
|
|
|
118
|
-
`
|
|
166
|
+
`store.get_or_refresh(key)` composes `Read::Get` with `Write::RefreshOrchestrator` to optionally refresh on stale.
|
|
119
167
|
|
|
120
|
-
## Write path (`
|
|
168
|
+
## Write path (`store.put(key, ...)`)
|
|
121
169
|
|
|
122
|
-
1. CLI verb calls `
|
|
123
|
-
2. `Write::Put
|
|
124
|
-
3. Delegates persistence to `
|
|
125
|
-
4. Publishes `:entry_put` via `
|
|
170
|
+
1. CLI verb calls `store.put(key, meta:, body:, content:, if_etag:, role:)`.
|
|
171
|
+
2. `Write::Put#call` validates the key, resolves the manifest entry, and calls `container.authorizer.authorize_write!(mentry, role: call.role)` — raises `WriteForbidden` if denied.
|
|
172
|
+
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.
|
|
173
|
+
4. Publishes `:entry_put` via `container.events` with `ctx: <Hooks::Context>`, `key:`, `envelope:`.
|
|
126
174
|
|
|
127
|
-
`Write::{Delete,Mv,Accept,Reject,Publish}` follow the same shape: explicit
|
|
175
|
+
`Write::{Delete,Mv,Accept,Reject,Publish}` follow the same shape: explicit container, `Authorizer` for authz, `Envelope::IO::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
|
|
128
176
|
|
|
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.
|
|
177
|
+
`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.
|
|
130
178
|
|
|
131
|
-
## Refresh path (`
|
|
179
|
+
## Refresh path (`store.refresh(key)`)
|
|
132
180
|
|
|
133
|
-
1. CLI `Verb::Refresh`
|
|
134
|
-
2. `Write::RefreshWorker
|
|
135
|
-
- Resolves the manifest entry, looks up the intake handler via `
|
|
181
|
+
1. CLI `Verb::Refresh` calls `store.refresh(key, role: "runner")`.
|
|
182
|
+
2. `Write::RefreshWorker#run(key)`:
|
|
183
|
+
- Resolves the manifest entry, looks up the intake handler via `container.rpc.callable(:resolve_intake, mentry.handler)`.
|
|
136
184
|
- Publishes `:refresh_started` with the hook context.
|
|
137
185
|
- Invokes the handler under a 30s thread-join deadline.
|
|
138
186
|
- On any error: publishes `:refresh_failed`, then re-raises.
|
|
139
|
-
- On success: applies
|
|
140
|
-
3. `
|
|
187
|
+
- On success: applies `container.authorizer.authorize_write!` and persists via `Envelope::IO::Writer#write` directly (no `Put` round-trip); publishes `:entry_refreshed` unless etag is unchanged.
|
|
188
|
+
3. `store.refresh_all(prefix:, zone:)` lists stale entries via `Read::Stale` and runs `Worker#run` per entry; returns `{ refreshed:, failed:, skipped: }`.
|
|
141
189
|
|
|
142
190
|
## Hook payload contract
|
|
143
191
|
|
|
144
|
-
Pub-sub hooks (`:entry_put`, `:entry_refreshed`, …) receive `ctx:` — a `Textus::Hooks::Context` that
|
|
192
|
+
Pub-sub hooks (`:entry_put`, `:entry_refreshed`, …) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface (`get`, `list`, `put`, `delete`, `audit`, `publish_followup`, plus `role` and `correlation_id`). The raw `Store` is not handed out.
|
|
145
193
|
|
|
146
|
-
RPC hooks (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps:` — a `
|
|
194
|
+
RPC hooks (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps:` — a `Textus::Container`. They are gem-internal: the framework calls them, not user pub-sub.
|
|
147
195
|
|
|
148
196
|
## Agent surface (boot + pulse + MCP)
|
|
149
197
|
|
|
150
198
|
Agents and plugins talk to a textus store through three layers:
|
|
151
199
|
|
|
152
200
|
```
|
|
153
|
-
soul (skill/agent) ──▶ gate (CLI | MCP) ──▶
|
|
201
|
+
soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Store ──▶ memory (.textus/)
|
|
154
202
|
```
|
|
155
203
|
|
|
156
204
|
Two transports, one façade:
|
|
@@ -158,7 +206,7 @@ Two transports, one façade:
|
|
|
158
206
|
- **CLI** — human/script surface. `textus boot`, `textus pulse --since=N`, `textus get/put/...`.
|
|
159
207
|
- **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
208
|
|
|
161
|
-
Both transports call `
|
|
209
|
+
Both transports call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`). No duplicate logic.
|
|
162
210
|
|
|
163
211
|
The agent loop (cadence guide in `docs/agent-integration.md`):
|
|
164
212
|
|
|
@@ -170,6 +218,8 @@ Manifest drift surfaces as `ContractDrift` (manifest_etag mismatch); audit curso
|
|
|
170
218
|
|
|
171
219
|
## Hooks::EventBus event catalog
|
|
172
220
|
|
|
221
|
+
`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).
|
|
222
|
+
|
|
173
223
|
RPC (single handler, declares `caps:`):
|
|
174
224
|
- `resolve_intake(caps:, config:, args:)` — intake fetch handler.
|
|
175
225
|
- `transform_rows(caps:, rows:, config:)` — row transform for intakes.
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,138 @@ 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.30.0 — 2026-05-29
|
|
13
|
+
|
|
14
|
+
Explicit zone kind (strict) and entry retention (ADR 0028, moves 1 & 4). No wire format (`textus/3`) change.
|
|
15
|
+
|
|
16
|
+
### Changed (BREAKING)
|
|
17
|
+
|
|
18
|
+
- Zone `kind:` is now **required** on every zone (`origin | quarantine | queue | derived`); a manifest with a kind-less zone is rejected at load. The kind is authoritative: a zone is `derived` only if it declares `kind: derived`, and proposals route only to the zone declaring `kind: queue`. The previous writers→kind inference, the `"review"`-name proposal fallback, and boot's arbitrary-zone propose default were removed. No `textus/3` wire-format change; existing manifests must add `kind:` to every zone.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `Manifest::Policy#declared_kind`, `#queue_zone`, `#derived_zone?`. `propose_zone_for` now resolves through the declared `queue` zone exclusively.
|
|
23
|
+
- `retention:` rule block (`expire_after`, `archive_after`) parsed into `Domain::Policy::Retention`. New `textus retain --as=ROLE` sweep expires (deletes) or archives leaves past their window — `expire_after` deletes, `archive_after` copies to `.textus/archive/` then deletes; age is the leaf's mtime. `--prefix`/`--zone` narrow the sweep; rows whose zone the role can't write surface as failures. Retention appears in `textus rule explain`.
|
|
24
|
+
- `Textus::Domain::Duration.seconds` — shared duration parser (`30s`/`90m`/`12h`/`30d`/bare seconds), now also backing `Refresh#ttl_seconds`.
|
|
25
|
+
|
|
26
|
+
### Internal
|
|
27
|
+
|
|
28
|
+
- `Manifest::Entry::Base#in_generator_zone?` and `boot` derived/proposal detection route through `Policy#derived_zone?` / `#propose_zone_for`; all `"review"` substring matches and the `Policy#zone_kinds` inference method are removed.
|
|
29
|
+
- Dead `Policy#zone_kinds` method removed.
|
|
30
|
+
|
|
31
|
+
## 0.29.2 — 2026-05-29
|
|
32
|
+
|
|
33
|
+
Hook-registry convergence and MCP transport de-leak (ADR 0027). Every change is additive or internal — no wire format (`textus/3`) or manifest-schema change, no public class renamed or removed.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- `Textus::Hooks::Signature` — single home of callable keyword-introspection (`accepts_keyrest?`, `declared_keys`, `missing`, `filter`), shared by both `EventBus` and `RpcRegistry`.
|
|
38
|
+
- `Manifest::Policy#propose_zone_for(role)` — owns the "first writable zone whose name contains `review`" convention; `MCP::Server#handle_initialize` delegates to it instead of scanning `manifest.data.zones` inline.
|
|
39
|
+
|
|
40
|
+
### Internal
|
|
41
|
+
|
|
42
|
+
- `EventBus` and `RpcRegistry` both delegate callable introspection to `Hooks::Signature`; both `shape_check!` copies and the hand-rolled `filter_kwargs`/`invoke` derivations are deleted.
|
|
43
|
+
- Removed the `store:`→`caps:` legacy shim from `RpcRegistry`: a handler declaring `store:` (instead of `caps:`) is now rejected at registration time with an honest message, not at invoke time. Stale in-repo RPC hook fixtures and the `textus init` scaffold example are migrated to `caps:`.
|
|
44
|
+
- `MCP::Server#handle_initialize` no longer iterates `manifest.data.zones`; it calls `policy.propose_zone_for(proposer)`. No zone-selection logic remains in the JSON-RPC transport handler.
|
|
45
|
+
- `MCP::Session` converted from a hand-rolled immutable class to `Data.define(:role, :cursor, :propose_zone, :manifest_etag)`, matching the house convention used by all other value objects.
|
|
46
|
+
|
|
47
|
+
### Behavior change (non-breaking in practice)
|
|
48
|
+
|
|
49
|
+
- RPC handlers declaring `store:` previously registered successfully and failed only at first invocation (with a misleading message). They now fail at registration time with a message naming the correct kwarg (`caps:`). No handler using `store:` was valid before; only the timing and clarity of the error change.
|
|
50
|
+
|
|
51
|
+
## 0.29.1 — 2026-05-29
|
|
52
|
+
|
|
53
|
+
Construction-side cleanup of the use-case layer (ADR 0026). Every change is additive or internal — no public class renamed or removed, wire format (`textus/3`) and CLI unchanged.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- `Envelope::IO::Writer.from(container:, call:)` and `Envelope::IO::Reader.from(container:)` — named constructors that build the envelope IO collaborators from a `Container`. `Writer.new`/`Reader.new` are unchanged.
|
|
58
|
+
- `Write::IntakeFetch.invoke(rpc:, handler:, config:, args:, label:, timeout:)` — the transport-side "invoke a `:resolve_intake` handler under a timeout" kernel; now the canonical home of `FETCH_TIMEOUT_SECONDS`.
|
|
59
|
+
- `Dispatcher.invoke(verb, container:, call:, args:, kwargs:)` — single home for the uniform use-case invocation protocol.
|
|
60
|
+
|
|
61
|
+
### Internal
|
|
62
|
+
|
|
63
|
+
- `Write::{Put,Delete,Mv,RefreshWorker}` no longer hand-wire `Envelope::IO::Writer`/`Reader`; they call `Writer.from`. Removed ~60 lines of byte-identical construction boilerplate.
|
|
64
|
+
- `cli/verb/put.rb` (`--fetch`) and `cli/verb/hook_run.rb` no longer inline `Timeout.timeout { store.rpc.invoke(:resolve_intake, …) }`; both route through `Write::IntakeFetch`. No intake-fetch mechanics remain under `lib/textus/cli/`.
|
|
65
|
+
- `RoleScope`'s verb loop delegates the instantiate-and-call step to `Dispatcher.invoke`; it still builds the `Call`. `Store`'s role-selecting verb loop is unchanged.
|
|
66
|
+
- `RefreshWorker::FETCH_TIMEOUT_SECONDS` is now an alias of `IntakeFetch::FETCH_TIMEOUT_SECONDS`.
|
|
67
|
+
|
|
68
|
+
## 0.29.0 — 2026-05-29
|
|
69
|
+
|
|
70
|
+
A domain-purity pass that routes all filesystem and wall-clock I/O through injected ports. Breaking changes are Ruby-API only; the wire format (`textus/3`) and CLI are unchanged.
|
|
71
|
+
|
|
72
|
+
### Breaking
|
|
73
|
+
|
|
74
|
+
- `Domain::Staleness#initialize` now requires `file_stat:` and `clock:` (was `manifest:` only).
|
|
75
|
+
- `Domain::Staleness::IntakeCheck#initialize` now requires `file_stat:` and `clock:`.
|
|
76
|
+
- `Domain::Staleness::GeneratorCheck#initialize` now requires `file_stat:` (no clock — `GeneratorCheck` has no wall-clock dependency).
|
|
77
|
+
- `Domain::Sentinel` is now a pure value object. Its persistence class methods (`write!`, `load`, `sentinel_path`) and `SUFFIX`/`DIR` constants have moved to the new `Ports::SentinelStore`.
|
|
78
|
+
- `Domain::Sentinel#orphan?` and `#drift?` now take a `file_stat` argument.
|
|
79
|
+
- `Textus::Boot.run_via(container:, role:)` → `Textus::Boot.build(container:)` (the `role:` parameter was unused).
|
|
80
|
+
- `Textus::Doctor.run_via(container:, role:, checks:)` → `Textus::Doctor.build(container:, checks:)` (the `role:` parameter was unused).
|
|
81
|
+
- `RoleScope#boot` / `#doctor` are removed as special cases; `boot` and `doctor` are now entries in `Dispatcher::VERBS`. `store.boot`, `store.doctor`, and `store.as(role).boot` are unchanged.
|
|
82
|
+
|
|
83
|
+
### Added
|
|
84
|
+
|
|
85
|
+
- `Ports::Storage::FileStat` — read-only filesystem query port (`exists?`, `directory?`, `read`, `mtime`, `glob`); the narrow interface pure domain logic depends on (distinct from the write-side `FileStore`).
|
|
86
|
+
- `Ports::SentinelStore` — sentinel persistence + path-layout adapter, extracted from `Domain::Sentinel`.
|
|
87
|
+
- `Read::Boot` and `Read::Doctor` — dispatched use-case classes on the uniform `(container:, call:)` shape.
|
|
88
|
+
|
|
89
|
+
### Changed
|
|
90
|
+
|
|
91
|
+
- `manifest_etag` (in `pulse` output and the MCP session drift token) is now the system-standard `sha256:`-prefixed etag, computed via `FileStore#etag`, instead of a bare SHA-256 hex digest. The token is opaque (compared for equality, never parsed).
|
|
92
|
+
|
|
93
|
+
### Internal
|
|
94
|
+
|
|
95
|
+
- The domain layer no longer performs direct filesystem or wall-clock I/O; all disk/clock access is routed through injected ports (`FileStat`, `Clock`). Enforced by a new `spec/domain_purity_spec.rb` that fails on any regression.
|
|
96
|
+
- Freshness request timestamps now originate from `Ports::Clock` (via `Call.build`) rather than a bare `Time.now`.
|
|
97
|
+
- Cosmetic refactors: deduped the audit limit guard; made `RefreshWorker.normalize_action_result` a public class method (dropped a `send`); extracted staleness guard helpers.
|
|
98
|
+
- New guard spec `spec/no_handrolled_manifest_etag_spec.rb` forbids `Digest::SHA256.hexdigest(File.read(...))` from reappearing in `lib/` (exempt: `etag.rb` and `sentinel_store.rb`, the latter being a wire-pinned integrity checksum, not an etag).
|
|
99
|
+
- See [ADR 0024](docs/architecture/decisions/0024-domain-purity-ports.md) for the design rationale.
|
|
100
|
+
|
|
101
|
+
## 0.28.0 — 2026-05-29
|
|
102
|
+
|
|
103
|
+
A consistency-and-cleanup pass that finishes the seams [ADR 0022](docs/architecture/decisions/0022-container-call-dispatcher.md) left behind. Breaking changes are Ruby-API only.
|
|
104
|
+
|
|
105
|
+
### Breaking
|
|
106
|
+
|
|
107
|
+
- Use-case constructors no longer accept `hook_context:`. Use cases that emit events derive their `Hooks::Context` internally from `(container, call)` via the new `Textus::Hooks::Context.for(container:, call:)` factory. Every use case now has the uniform shape `def initialize(container:, call:)`.
|
|
108
|
+
- `Textus::Envelope::IO::Writer` and `Textus::Write::RefreshOrchestrator` constructors take `call:` instead of `ctx:` (both received a `Call` already; the kwarg name is corrected).
|
|
109
|
+
- `Read::Audit#call` now accepts filter keywords and builds a `Read::Audit::Query` value object internally — keyword callers (`store.audit(key:, limit:)`) are unchanged.
|
|
110
|
+
- `Builder::Pipeline.run` takes `(mentry:, deps:)` where `deps` is a `Builder::Pipeline::Deps` record, instead of eight loose keyword collaborators.
|
|
111
|
+
- Removed the `CLI::VERBS` const-missing shim (use `CLI.verbs`).
|
|
112
|
+
- Removed the `Manifest::Entry::PUBLISH_EACH_VARS` / `PUBLISH_EACH_VAR_RE` re-exports (use `Manifest::Entry::Validators::PublishEach::KNOWN_VARS` / `::VAR_RE`).
|
|
113
|
+
|
|
114
|
+
### Internal
|
|
115
|
+
|
|
116
|
+
- Removed the runtime `initialize`-parameter reflection from both `RoleScope` and `Doctor::Check`; verb dispatch is now an unconditional `klass.new(container:, call:).call(...)`.
|
|
117
|
+
- `Lint/UnusedMethodArgument` disables dropped from 27 to 20; two `Metrics/ParameterLists` (and two complexity) disables removed by the value-object refactors. `Metrics/ParameterLists` ceiling documented and kept at `Max 6` (the honest ceiling for value-object constructors, `AuditLog#append`, and the public `put` API).
|
|
118
|
+
- `ARCHITECTURE.md`'s "uniform `(container:, call:)`" claim is now accurate; active docs refreshed to the 0.27/0.28 vocabulary.
|
|
119
|
+
- No wire-format change. Protocol stays at `textus/3`. CLI verb signatures unchanged. Hook callable surfaces (`ctx:` for pub-sub, `caps:` for RPC) unchanged.
|
|
120
|
+
- See [ADR 0023](docs/architecture/decisions/0023-uniform-use-case-shape.md) for the design rationale.
|
|
121
|
+
|
|
122
|
+
## 0.27.0 — 2026-05-29
|
|
123
|
+
|
|
124
|
+
### Breaking
|
|
125
|
+
|
|
126
|
+
- Removed `Textus::Session`. Use `store.as(role).put(...)` or `store.put(..., role:)` instead of `store.session(role:).put(...)`.
|
|
127
|
+
- Removed `Textus::Application::UseCase` registry. Verb dispatch is now via the static `Textus::Dispatcher::VERBS` table.
|
|
128
|
+
- Replaced `Textus::Application::ReadCaps` / `WriteCaps` / `HookCaps` with a single `Textus::Container` record (field names preserved: `manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc`, `authorizer`).
|
|
129
|
+
- Renamed `Textus::Application::Context` to `Textus::Call`. Field shape identical.
|
|
130
|
+
- Use-case classes are no longer `module Foo; def self.call; Impl.new(...).call; end`. They are plain classes: `class Foo; def initialize(container:, call:); def call(...); end`.
|
|
131
|
+
- Flattened `Textus::Application::Write::*` → `Textus::Write::*`, `Application::Read::*` → `Read::*`, `Application::Envelope::*` → `Envelope::IO::*`, `Application::Maintenance::*` → `Maintenance::*`, `Application::Projection` → `Projection`.
|
|
132
|
+
- Renamed `Textus::Infra::*` → `Textus::Ports::*`.
|
|
133
|
+
- `Manifest::Entry::Base#zone_writers` / `#in_generator_zone?` / `#in_proposal_zone?` now take an explicit `policy` argument; entries no longer carry an `@manifest` back-reference.
|
|
134
|
+
- `PublishContext` shrunk from 12 fields to `(container, call, reader)` with derived accessors. Custom derived entries that destructured `pctx.caps` / `pctx.session` / `pctx.ctx` / `pctx.bus` need to use `pctx.container` / construct a `RoleScope` / `pctx.call` / `pctx.events`.
|
|
135
|
+
- Hook RPC callables (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps: container` (a `Textus::Container`) instead of `caps: <WriteCaps>`. Field names preserved, so handlers reading `caps.manifest` / `caps.events` / etc. continue to work.
|
|
136
|
+
|
|
137
|
+
### Internal
|
|
138
|
+
|
|
139
|
+
- ~600 LOC removed net across ~60 files.
|
|
140
|
+
- No wire-format change. Protocol stays at `textus/3`.
|
|
141
|
+
- CLI verb signatures unchanged. No envelope shape changes.
|
|
142
|
+
- See [ADR 0022](docs/architecture/decisions/0022-container-call-dispatcher.md) for the design rationale.
|
|
143
|
+
|
|
12
144
|
## 0.26.0 — 2026-05-28
|
|
13
145
|
|
|
14
146
|
### Breaking
|
data/README.md
CHANGED
|
@@ -5,20 +5,62 @@
|
|
|
5
5
|
[](https://www.ruby-lang.org/)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
**Durable, multi-writer context for codebases that humans and AI agents both touch.** Your agent forgets everything between sessions; your runbooks and `CLAUDE.md` get edited by whoever ran last; nobody can reconstruct who wrote what. textus is the memory that survives the model, the session, and the vendor — a shared workspace where humans, agents, and runners write into separate lanes, propose changes through a review queue, and leave an audit trail behind every byte.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
*textus* is Latin for "the fabric a text is woven from" — same root as *context*, from *con-texere*, "to weave together." The protocol weaves human edits, agent proposals, and runner intake into one durable fabric. The shape of that fabric is yours; the rules for writing into it are textus's.
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## The idea
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Three actors write to your repo today:
|
|
15
15
|
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
16
|
+
- **Humans** — you, your team. Authoritative on identity, decisions, voice.
|
|
17
|
+
- **Agents** — Claude, Cursor, custom assistants. Smart, fast, forgetful, and not always right.
|
|
18
|
+
- **Runners** — cron jobs, fetchers, CI. Bring outside data in.
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
Without coordination, they overwrite each other and nothing remembers why. textus gives each actor a **lane** (a zone), routes everything they can't write directly through a **review queue**, and writes every successful change to an **append-only audit log**. The lanes are enforced at the protocol level, not by convention.
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
```
|
|
23
|
+
identity/ human only — who you are, what you decide, how you sound
|
|
24
|
+
working/ human only — day-to-day catalog (agents propose via review/, runners feed via intake/)
|
|
25
|
+
intake/ runner only — declared external inputs
|
|
26
|
+
review/ agent + human — proposals waiting on a human accept
|
|
27
|
+
output/ builder only — computed, published artifacts
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
An agent that tries to write directly into `working/` or `identity/` gets `write_forbidden`. It writes to `review/` instead. You accept the good proposals; textus promotes them, records the move, and audits both halves. Stable per-entry `uid:` means a reorganization doesn't break references. A monotonic audit cursor (`textus pulse --since=N`) means the next session — possibly a different agent, possibly a different model — picks up exactly where the last one left off.
|
|
31
|
+
|
|
32
|
+
That's the load-bearing claim: **coordination is a protocol invariant, not a library convenience.**
|
|
33
|
+
|
|
34
|
+
## See it in four commands
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
gem install textus
|
|
38
|
+
textus init # creates .textus/ with zones + schemas
|
|
39
|
+
# agent proposes a change to review/
|
|
40
|
+
printf '%s' '{"_meta":{"name":"oncall","proposal":{"target_key":"working.notes.oncall","action":"put"}},"body":"Patrick on call.\n"}' \
|
|
41
|
+
| textus put review.notes.oncall --as=agent --stdin
|
|
42
|
+
# you accept it — textus promotes to working/ and audits the move
|
|
43
|
+
textus accept review.notes.oncall --as=human
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Try the gate the other way (`textus put working.notes.X --as=agent`) and you get `write_forbidden`, with the role that *would* be allowed named in the error. That refusal is the whole point.
|
|
47
|
+
|
|
48
|
+
## Try it
|
|
49
|
+
|
|
50
|
+
- **5-command worked demo** — single terminal scroll, no MCP, no schemas: [`examples/hello/`](examples/hello/)
|
|
51
|
+
- **Wire textus into Claude Code via MCP** — 4 steps, ~5 minutes: [`INTEGRATE_WITH_CLAUDE.md`](INTEGRATE_WITH_CLAUDE.md)
|
|
52
|
+
- **Use textus as your own project's context store**: [`examples/project/`](examples/project/)
|
|
53
|
+
- **Use textus to author a Claude plugin** (textus is the source-of-truth, build publishes to `agents/`, `skills/`, `commands/`): [`examples/claude-plugin/`](examples/claude-plugin/)
|
|
54
|
+
|
|
55
|
+
## Protocol, not just a gem
|
|
56
|
+
|
|
57
|
+
This Ruby gem is the reference implementation of **`textus/3`** — a wire format and storage convention any language can speak. The protocol owns the envelope shape, the role/zone gate, the audit log format, and the key grammar. The gem version (semver, see badge) and the protocol version (`textus/3`) move independently; envelopes carry the `protocol` field so consumers can pin to the contract, not the implementation.
|
|
58
|
+
|
|
59
|
+
- Specification: [`SPEC.md`](SPEC.md)
|
|
60
|
+
- Architecture: [`ARCHITECTURE.md`](ARCHITECTURE.md)
|
|
61
|
+
- Per-release notes: [`CHANGELOG.md`](CHANGELOG.md)
|
|
62
|
+
|
|
63
|
+
A second implementation in another language would share the same `.textus/` directory and the same audit log. That's deliberate.
|
|
22
64
|
|
|
23
65
|
## Install
|
|
24
66
|
|
|
@@ -57,15 +99,15 @@ You get `.textus/` with all five zone directories, baseline schemas, an empty au
|
|
|
57
99
|
output/ # builder only — computed outputs
|
|
58
100
|
```
|
|
59
101
|
|
|
60
|
-
Manifest `path:` fields are relative to `.textus/zones/`. So `working.
|
|
102
|
+
Manifest `path:` fields are relative to `.textus/zones/`. So `working.notes.org.jane` lives at `.textus/zones/working/notes/org/jane.md`.
|
|
61
103
|
|
|
62
104
|
Read and write:
|
|
63
105
|
|
|
64
106
|
```sh
|
|
65
|
-
textus get working.
|
|
107
|
+
textus get working.notes.org.jane
|
|
66
108
|
textus list --zone=working
|
|
67
|
-
|
|
68
|
-
| textus put working.
|
|
109
|
+
printf '%s' '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
|
|
110
|
+
| textus put working.notes.bob --as=human --stdin
|
|
69
111
|
textus freshness --zone=output # per-entry fresh/stale/never_refreshed/no_policy
|
|
70
112
|
textus rule list # show every rule block
|
|
71
113
|
textus audit --limit=20 # query the audit log
|
|
@@ -75,17 +117,17 @@ textus audit --limit=20 # query the audit log
|
|
|
75
117
|
|
|
76
118
|
For the full shape — Claude plugin with agents, skills, commands, pending walkthrough, intake action — see [`examples/claude-plugin/`](examples/claude-plugin/).
|
|
77
119
|
|
|
78
|
-
## What
|
|
120
|
+
## What's shipped
|
|
79
121
|
|
|
80
122
|
- **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
123
|
- **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
|
-
- **Build and publish in one pass.** `
|
|
83
|
-
- **Typed envelopes
|
|
124
|
+
- **Build and publish in one pass.** `Textus::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.
|
|
125
|
+
- **Typed envelopes.** `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
126
|
- **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
|
-
- **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `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).
|
|
127
|
+
- **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus doctor` flags any illegal segments with a rename hint; `textus key mv old.key new.key` renames in place (uid survives).
|
|
128
|
+
- **`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).
|
|
87
129
|
- **`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.
|
|
88
|
-
- **`textus doctor`.** Health check across
|
|
130
|
+
- **`textus doctor`.** Health check across 15 checks — among them: 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.
|
|
89
131
|
- **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.
|
|
90
132
|
- **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.
|
|
91
133
|
|
|
@@ -163,7 +205,7 @@ See [`docs/agent-integration.md`](docs/agent-integration.md) for the agent boot
|
|
|
163
205
|
bundle exec rspec
|
|
164
206
|
```
|
|
165
207
|
|
|
166
|
-
~
|
|
208
|
+
~920 examples; includes conformance fixtures A–I from SPEC §12.
|
|
167
209
|
|
|
168
210
|
## Code quality
|
|
169
211
|
|