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.
Files changed (175) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +148 -45
  3. data/CHANGELOG.md +194 -0
  4. data/README.md +8 -5
  5. data/SPEC.md +54 -15
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/application/caps.rb +49 -0
  8. data/lib/textus/application/context.rb +2 -2
  9. data/lib/textus/application/envelope/reader.rb +44 -0
  10. data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
  11. data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
  12. data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
  13. data/lib/textus/application/maintenance/migrate.rb +59 -0
  14. data/lib/textus/application/maintenance/rule_lint.rb +65 -0
  15. data/lib/textus/application/maintenance/zone_mv.rb +60 -0
  16. data/lib/textus/application/maintenance.rb +17 -0
  17. data/lib/textus/application/projection.rb +12 -10
  18. data/lib/textus/application/read/audit.rb +106 -0
  19. data/lib/textus/application/read/blame.rb +91 -0
  20. data/lib/textus/application/read/deps.rb +34 -0
  21. data/lib/textus/application/read/freshness.rb +110 -0
  22. data/lib/textus/application/read/get.rb +75 -0
  23. data/lib/textus/application/read/get_or_refresh.rb +63 -0
  24. data/lib/textus/application/read/list.rb +25 -0
  25. data/lib/textus/application/read/policy_explain.rb +47 -0
  26. data/lib/textus/application/read/published.rb +25 -0
  27. data/lib/textus/application/read/pulse.rb +101 -0
  28. data/lib/textus/application/read/rdeps.rb +35 -0
  29. data/lib/textus/application/read/schema_envelope.rb +26 -0
  30. data/lib/textus/application/read/stale.rb +23 -0
  31. data/lib/textus/application/read/uid.rb +30 -0
  32. data/lib/textus/application/read/validate_all.rb +32 -0
  33. data/lib/textus/application/{reads → read}/validator.rb +2 -2
  34. data/lib/textus/application/read/where.rb +26 -0
  35. data/lib/textus/application/use_case.rb +22 -0
  36. data/lib/textus/application/write/accept.rb +102 -0
  37. data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
  38. data/lib/textus/application/write/delete.rb +45 -0
  39. data/lib/textus/application/{writes → write}/materializer.rb +14 -15
  40. data/lib/textus/application/write/mv.rb +118 -0
  41. data/lib/textus/application/write/publish.rb +96 -0
  42. data/lib/textus/application/write/put.rb +49 -0
  43. data/lib/textus/application/write/refresh_all.rb +63 -0
  44. data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
  45. data/lib/textus/application/write/refresh_worker.rb +134 -0
  46. data/lib/textus/application/write/reject.rb +62 -0
  47. data/lib/textus/{intro.rb → boot.rb} +49 -29
  48. data/lib/textus/builder/pipeline.rb +5 -5
  49. data/lib/textus/cli/group/mcp.rb +9 -0
  50. data/lib/textus/cli/group/zone.rb +9 -0
  51. data/lib/textus/cli/verb/accept.rb +1 -1
  52. data/lib/textus/cli/verb/audit.rb +4 -2
  53. data/lib/textus/cli/verb/blame.rb +1 -1
  54. data/lib/textus/cli/verb/boot.rb +13 -0
  55. data/lib/textus/cli/verb/build.rb +2 -2
  56. data/lib/textus/cli/verb/delete.rb +1 -1
  57. data/lib/textus/cli/verb/deps.rb +1 -1
  58. data/lib/textus/cli/verb/doctor.rb +1 -1
  59. data/lib/textus/cli/verb/freshness.rb +1 -1
  60. data/lib/textus/cli/verb/get.rb +1 -1
  61. data/lib/textus/cli/verb/hook_run.rb +3 -4
  62. data/lib/textus/cli/verb/hooks.rb +11 -14
  63. data/lib/textus/cli/verb/key_delete.rb +24 -0
  64. data/lib/textus/cli/verb/list.rb +1 -1
  65. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  66. data/lib/textus/cli/verb/migrate.rb +18 -0
  67. data/lib/textus/cli/verb/mv.rb +11 -3
  68. data/lib/textus/cli/verb/published.rb +1 -1
  69. data/lib/textus/cli/verb/pulse.rb +17 -0
  70. data/lib/textus/cli/verb/put.rb +8 -6
  71. data/lib/textus/cli/verb/rdeps.rb +1 -1
  72. data/lib/textus/cli/verb/refresh.rb +1 -1
  73. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  74. data/lib/textus/cli/verb/reject.rb +1 -1
  75. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  76. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  77. data/lib/textus/cli/verb/schema.rb +1 -1
  78. data/lib/textus/cli/verb/uid.rb +1 -1
  79. data/lib/textus/cli/verb/where.rb +1 -1
  80. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  81. data/lib/textus/cli/verb.rb +4 -4
  82. data/lib/textus/cli.rb +1 -1
  83. data/lib/textus/doctor/check/audit_log.rb +2 -2
  84. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  85. data/lib/textus/doctor/check/hooks.rb +4 -3
  86. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  87. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  88. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  89. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  90. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  91. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  92. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  93. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  94. data/lib/textus/doctor/check/schemas.rb +2 -2
  95. data/lib/textus/doctor/check/sentinels.rb +2 -2
  96. data/lib/textus/doctor/check/templates.rb +2 -2
  97. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  98. data/lib/textus/doctor/check.rb +5 -3
  99. data/lib/textus/doctor.rb +24 -27
  100. data/lib/textus/domain/authorizer.rb +4 -4
  101. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  102. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  103. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  104. data/lib/textus/domain/staleness/generator_check.rb +2 -2
  105. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  106. data/lib/textus/domain/staleness.rb +1 -1
  107. data/lib/textus/errors.rb +16 -0
  108. data/lib/textus/hooks/builtin.rb +14 -14
  109. data/lib/textus/hooks/context.rb +13 -13
  110. data/lib/textus/hooks/error_log.rb +32 -0
  111. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  112. data/lib/textus/hooks/loader.rb +29 -3
  113. data/lib/textus/hooks/rpc_registry.rb +77 -0
  114. data/lib/textus/infra/audit_log.rb +126 -16
  115. data/lib/textus/infra/audit_subscriber.rb +6 -7
  116. data/lib/textus/infra/refresh/detached.rb +1 -1
  117. data/lib/textus/key/path.rb +7 -3
  118. data/lib/textus/manifest/data.rb +78 -0
  119. data/lib/textus/manifest/entry/base.rb +44 -7
  120. data/lib/textus/manifest/entry/derived.rb +41 -6
  121. data/lib/textus/manifest/entry/intake.rb +15 -3
  122. data/lib/textus/manifest/entry/leaf.rb +6 -5
  123. data/lib/textus/manifest/entry/nested.rb +42 -3
  124. data/lib/textus/manifest/entry/parser.rb +8 -44
  125. data/lib/textus/manifest/entry/validators/events.rb +2 -2
  126. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
  127. data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
  128. data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
  129. data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
  130. data/lib/textus/manifest/entry/validators.rb +1 -1
  131. data/lib/textus/manifest/entry.rb +3 -0
  132. data/lib/textus/manifest/policy.rb +48 -0
  133. data/lib/textus/manifest/resolver.rb +18 -18
  134. data/lib/textus/manifest/rules.rb +1 -1
  135. data/lib/textus/manifest/schema.rb +20 -6
  136. data/lib/textus/manifest.rb +53 -101
  137. data/lib/textus/mcp/errors.rb +32 -0
  138. data/lib/textus/mcp/server.rb +127 -0
  139. data/lib/textus/mcp/session.rb +31 -0
  140. data/lib/textus/mcp/tool_schemas.rb +71 -0
  141. data/lib/textus/mcp/tools.rb +129 -0
  142. data/lib/textus/mcp.rb +6 -0
  143. data/lib/textus/schema/tools.rb +14 -10
  144. data/lib/textus/session.rb +84 -0
  145. data/lib/textus/store.rb +17 -8
  146. data/lib/textus/version.rb +1 -1
  147. data/lib/textus.rb +8 -1
  148. metadata +65 -38
  149. data/lib/textus/application/reads/audit.rb +0 -69
  150. data/lib/textus/application/reads/blame.rb +0 -82
  151. data/lib/textus/application/reads/deps.rb +0 -26
  152. data/lib/textus/application/reads/freshness.rb +0 -88
  153. data/lib/textus/application/reads/get.rb +0 -67
  154. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  155. data/lib/textus/application/reads/list.rb +0 -17
  156. data/lib/textus/application/reads/policy_explain.rb +0 -39
  157. data/lib/textus/application/reads/published.rb +0 -17
  158. data/lib/textus/application/reads/rdeps.rb +0 -27
  159. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  160. data/lib/textus/application/reads/stale.rb +0 -15
  161. data/lib/textus/application/reads/uid.rb +0 -23
  162. data/lib/textus/application/reads/validate_all.rb +0 -24
  163. data/lib/textus/application/reads/where.rb +0 -18
  164. data/lib/textus/application/refresh/all.rb +0 -52
  165. data/lib/textus/application/refresh/worker.rb +0 -116
  166. data/lib/textus/application/writes/accept.rb +0 -89
  167. data/lib/textus/application/writes/delete.rb +0 -33
  168. data/lib/textus/application/writes/mv.rb +0 -105
  169. data/lib/textus/application/writes/publish.rb +0 -162
  170. data/lib/textus/application/writes/put.rb +0 -37
  171. data/lib/textus/application/writes/reject.rb +0 -50
  172. data/lib/textus/cli/verb/intro.rb +0 -13
  173. data/lib/textus/infra/event_bus.rb +0 -27
  174. data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
  175. data/lib/textus/operations.rb +0 -169
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 35d3b6540fc043048133df0874e76b36e522d844da783b69a5e8993418157ae4
4
- data.tar.gz: e0293b87efb32c1edf2d6c0103dcd94289d91031a4be570f8fdd40fb681c1e79
3
+ metadata.gz: e822d94de7ae06481dd8c03eab813a097b62e78121d94c9d1045017675da665c
4
+ data.tar.gz: 3372f74bb31465b28be8e6ce5b4721a3f15cae7536ebdfc8ef9b6c6d7c9f2d88
5
5
  SHA512:
6
- metadata.gz: '039b6f44941ea52ac8d956297d9619c4cc8b2346e7d8bced24bc5671506726a44348da66791aa6d032e56dd403353d1b34e9b2fee37eaec05cd6c83d5defc715'
7
- data.tar.gz: e6e5e8f97f5f9a07a03813d61f9efa2088fb8f1338af89ba1298bf307c6c72e89f1028aa5a67ffdf8f97f2151c0af1273f82ff80751c59c11aa93ef2953323a9
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: ops = Operations.for(store, role:)
6
- ops.<name>(...) # flat methods, one per use
7
- │ # case (put/get/refresh/…)
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
- Or, for embedders bringing their own ports:
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
- Operations (flat facade; inline use-case factories) │
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
- reads/{get,list,where,uid,schema_envelope,deps,rdeps,
21
- published,stale,validate_all,freshness,audit,
22
- blame,policy_explain,get_or_refresh}.rb
23
- writes/{put,delete,mv,accept,reject,build,publish,
24
- envelope_io}.rb
25
- refresh/{worker,orchestrator,all}.rb
26
- policy/{promotion,predicates/{schema_valid,human_accept}}
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
- Action Outcome
34
- Policy::{Promote,Refresh,Matcher,HandlerAllowlist}
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 (Entry, Rules, Schema, permission_for)
48
+ │ Manifest (Data, Resolver, Policy, Rules)
42
49
  │ Schemas (eager-load cache) │
43
- │ AuditLog
44
- Hooks::{Registry,Dispatcher,Loader,FireReport}
45
- Infra::{Publisher,EventBus,Clock,Refresh::Lock,
46
- Refresh::Detached,BuildLock,AuditSubscriber}
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 ports in their constructor.
59
+ Use cases declare their real collaborators in their Impl
60
+ constructor; UseCase.register hooks them into Session.
53
61
  ```
54
62
 
55
- ## Read path (`ops.get(key)`)
63
+ ## How a verb becomes a method
56
64
 
57
- 1. CLI verb (or any external caller) builds `ops = Textus::Operations.for(store, role:)` then `ops.get(key)`.
58
- 2. `Operations#get` constructs `Application::Reads::Get.new(ctx:, manifest:, file_store:)` and calls it.
59
- 3. `Reads::Get#call(key)` resolves the path through `@manifest`, reads bytes via `@file_store`, parses the envelope.
60
- 4. Looks up the refresh policy via `@manifest.rules_for(key)`. If absent, returns the envelope annotated fresh.
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
- `ops.get_or_refresh(key)` composes `Reads::Get` with `Refresh::Orchestrator` to optionally refresh on stale — same as 0.18.x.
118
+ `session.get_or_refresh(key)` composes `Read::Get` with `Write::RefreshOrchestrator` to optionally refresh on stale.
64
119
 
65
- ## Write path (`ops.put(key, ...)`)
120
+ ## Write path (`session.put(key, ...)`)
66
121
 
67
- 1. CLI verb calls `ops = Operations.for(store, role:)` then `ops.put(key, meta:, body:, content:, if_etag:)`.
68
- 2. `Writes::Put#call` validates the key, resolves the manifest entry, and calls `@authorizer.authorize_write!(mentry, role: @ctx.role)` — raises `WriteForbidden` if denied.
69
- 3. Delegates persistence to `EnvelopeIO#write`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
70
- 4. Publishes `:entry_put` via `@bus` with `store: @store`, `key:`, `envelope:`, `role: @ctx.role`, `correlation_id: @ctx.correlation_id`.
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
- `Writes::{Delete,Mv,Accept,Reject,Build,Publish}` follow the same shape: explicit ports, `Authorizer` for authz, `EnvelopeIO` for persistence (where applicable), event published with `store: real Store + role:` in payload.
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
- `Writes::Mv` delegates the file-move + audit to `EnvelopeIO#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `EnvelopeIO#write` directly — no `Put` bypass.
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 (`ops.refresh(key)`)
131
+ ## Refresh path (`session.refresh(key)`)
77
132
 
78
- 1. CLI `Verb::Refresh` builds `ops = Operations.for(store, role: "runner")` then calls `ops.refresh(key)`.
79
- 2. `Refresh::Worker#run(key)`:
80
- - Resolves the manifest entry, looks up the intake handler via `@registry.rpc_callable(:resolve_intake, mentry.intake_handler)`.
81
- - Publishes `:refresh_started` with `role:` in the payload.
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 `EnvelopeIO#write` directly (no `Put` round-trip); publishes `:entry_refreshed` unless etag is unchanged.
85
- 3. `ops.refresh_all(prefix:, zone:)` lists stale entries via `Reads::Stale` and runs `Worker#run` per entry; returns `{ refreshed:, failed:, skipped: }`.
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
- Hooks/intakes/transforms receive the actual `Textus::Store` (the composition root) as `store:`. Every write/refresh event payload carries `role:` directly so hook authors observe the actor without reaching through `store:`.
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
  [![Ruby](https://img.shields.io/badge/ruby-%E2%89%A53.3-CC342D.svg)](https://www.ruby-lang.org/)
6
6
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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 intro`) and know what to read, what to write, and what's off-limits.
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
- - **Split build/publish (v0.14.0).** `Application::Writes::Build` materializes generator-zone entries; `Application::Writes::Publish` copies nested leaves to `publish_each` targets. The `textus build` CLI verb calls both and merges results, so wire output is unchanged.
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 intro`.** 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. The boot signal for any agent — one tool call and it knows your store.
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 intro` 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
+ `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 `inject_intro:` flag that puts an orientation preamble at the top of `CLAUDE.md`.
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
- **`inject_intro:`.** A derived entry with a `template:` MAY declare `inject_intro: true`. When the builder materializes the entry, it merges the `textus intro` envelope (§9) into the projection data under the key `intro`, 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 intro` exposes.
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
- | `intro [--output=json]` | read | any |
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: `Reads::Get`, `Refresh::Worker`, `Refresh::Orchestrator`, `Refresh::All`. Orchestrate domain + infra; no business rules.
854
- - **Domain** (`lib/textus/domain/`) — Pure values: `Freshness::Policy`, `Action`, `Outcome`, `Freshness::Verdict`, `Freshness::Evaluator`. No I/O, no globals, testable without disk.
855
- - **Infrastructure** (`lib/textus/infra/`) — Adapters: `EventBus`, `Clock`, `Refresh::Lock`, `Refresh::Detached`. Wrap OS / library primitives.
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::Reads::Get`, which takes a `Context` and dispatches refresh via `Application::Refresh::Orchestrator`.
864
- - **Writes** flow through `Application::Writes::{Put,Delete,Build,Accept,Publish}`, each taking a `Context`. Permission checks happen at the use-case layer (via `Context#can_write?`); I/O happens at `Store::Writer#write_envelope_to_disk` (pure).
865
- - `Application::Context` is the universal request object: it carries `store`, `role`, `correlation_id`, `clock`, and `dry_run`. Use cases never thread these as separate kwargs.
866
- - `Textus::Operations` is the factory CLI verbs (and future MCP server / HTTP shim)
867
- use to construct Contexts and use cases. `Operations.for(store, role:)` returns
868
- a flat facade exposing one method per use case (`#put`, `#get`, `#refresh`, …);
869
- internal use-case instances are memoized via `||=` and live under
870
- `lib/textus/application/{reads,writes,refresh}/`.
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