textus 0.20.0 → 0.22.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -0
  3. data/README.md +7 -4
  4. data/SPEC.md +77 -5
  5. data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
  6. data/lib/textus/application/policy/promotion.rb +6 -11
  7. data/lib/textus/application/reads/audit.rb +40 -15
  8. data/lib/textus/application/reads/pulse.rb +63 -0
  9. data/lib/textus/application/reads/validator.rb +3 -1
  10. data/lib/textus/application/writes/accept.rb +5 -1
  11. data/lib/textus/application/writes/authority_gate.rb +26 -0
  12. data/lib/textus/application/writes/materializer.rb +1 -1
  13. data/lib/textus/application/writes/publish.rb +25 -106
  14. data/lib/textus/application/writes/reject.rb +5 -1
  15. data/lib/textus/{intro.rb → boot.rb} +71 -25
  16. data/lib/textus/builder/pipeline.rb +2 -2
  17. data/lib/textus/cli/verb/audit.rb +2 -0
  18. data/lib/textus/cli/verb/{intro.rb → boot.rb} +3 -3
  19. data/lib/textus/cli/verb/build.rb +2 -1
  20. data/lib/textus/cli/verb/pulse.rb +17 -0
  21. data/lib/textus/cli.rb +1 -1
  22. data/lib/textus/doctor/check/illegal_keys.rb +2 -3
  23. data/lib/textus/domain/policy/promote.rb +4 -2
  24. data/lib/textus/domain/policy/refresh.rb +2 -0
  25. data/lib/textus/errors.rb +16 -0
  26. data/lib/textus/infra/audit_log.rb +126 -16
  27. data/lib/textus/manifest/entry/base.rb +43 -6
  28. data/lib/textus/manifest/entry/derived.rb +40 -4
  29. data/lib/textus/manifest/entry/intake.rb +15 -3
  30. data/lib/textus/manifest/entry/leaf.rb +6 -5
  31. data/lib/textus/manifest/entry/nested.rb +42 -3
  32. data/lib/textus/manifest/entry/parser.rb +9 -51
  33. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  34. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
  35. data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
  36. data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
  37. data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
  38. data/lib/textus/manifest/entry/validators.rb +1 -1
  39. data/lib/textus/manifest/entry.rb +3 -0
  40. data/lib/textus/manifest/resolver.rb +8 -5
  41. data/lib/textus/manifest/role_kinds.rb +21 -0
  42. data/lib/textus/manifest/schema.rb +63 -5
  43. data/lib/textus/manifest.rb +31 -1
  44. data/lib/textus/operations.rb +8 -1
  45. data/lib/textus/schema/tools.rb +8 -1
  46. data/lib/textus/store.rb +5 -1
  47. data/lib/textus/version.rb +1 -1
  48. metadata +9 -10
  49. data/lib/textus/application/policy/predicates/human_accept.rb +0 -30
  50. data/lib/textus/application/tools/migrate_keys.rb +0 -191
  51. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +0 -31
  52. data/lib/textus/cli/verb/key_normalize.rb +0 -48
  53. data/lib/textus/domain/policy.rb +0 -7
  54. data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
  55. data/lib/textus/manifest/resolution.rb +0 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 613e04300389a5f5bf058f4e75c15b5ff19f813fb7ef6102ba38ce53ecd43043
4
- data.tar.gz: 97d121b40ac753af1e04893429db1abfe4044f791fff5ea02de33910a4cfa00c
3
+ metadata.gz: 38a9a929bb63f94d5a3cdc4708f09ce5c8e0eca28928d5733b8d842d90ece8b8
4
+ data.tar.gz: 26a8aeb22666788cd30e949eb25c7336db1de9ef7f4110aaf700f7abecdec644
5
5
  SHA512:
6
- metadata.gz: 13d11e92b35b952fc9974293544ab49d50dc6055f7ee80771b33a82f511ba534f6cf3fa8c26a84e886778f6b5ee662a3a540d36c7072c23f2b366540a365e066
7
- data.tar.gz: 7cbf43d42e37271b73122d5db10a463a1936c39696cc6a5e7eee56646ccdb1c503a645e63b3b40e09bfc911ae531e2258b02d4e715458bcd54a193d9e7ca5b0d
6
+ metadata.gz: 9b4ce0da2828f2623f60601cdd0ac8a69e51cc9670c5de80b92e3b41e0f955361154664b8cb0a7de0e838b5403336ff26589698201885b44001a83cfcd2c3f21
7
+ data.tar.gz: 672d948ab0ccdea1470dcb38c87c233ed717a538d4e4902191a32224dac8475fe1269b4569be00e5eb1f40e9c6146f8867d557d6455601c13fc2e0ce2c381cae
data/CHANGELOG.md CHANGED
@@ -9,6 +9,163 @@ 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.22.0 — 2026-05-28
13
+
14
+ ### Changed (internal — no manifest-schema impact)
15
+ - **Entry polymorphism pass.** Behavior-preserving refactor that
16
+ consolidates cross-cutting fields on `Manifest::Entry::Base` and
17
+ replaces case-statement dispatch with polymorphic methods. Adding
18
+ a new entry kind now costs ~1 file edit instead of ~5–10.
19
+ - `publish_to` is now owned by `Base` (was declared four separate
20
+ times across Leaf/Derived/Nested/Intake).
21
+ - `Base` exposes nil-returning stubs for `template`, `inject_boot`,
22
+ `events`, `publish_each`, `index_filename` — validators and
23
+ serializers no longer need `respond_to?` guards.
24
+ - `Publish#call` dispatches via `entry.publish_via(context)` instead
25
+ of a 4-branch case-statement. The byte-identical
26
+ `publish_leaf_entry` / `publish_intake_entry` helpers are gone.
27
+ - Each `Entry` subclass declares a `KIND` constant and a
28
+ `self.from_raw(common, raw)` factory; `Parser` dispatches via
29
+ `Entry::REGISTRY` instead of a closed `case kind`.
30
+ - Dead `Base#kind` method removed.
31
+
32
+ No public API or manifest YAML changes. All existing manifests load
33
+ identically.
34
+
35
+ Remaining `is_a?(Entry::Derived)` callsites in `builder/`, `renderer/`,
36
+ `application/reads/`, and `domain/staleness/` are out of scope for this
37
+ pass — they touch a different polymorphism axis (what data the entry
38
+ contributes to a build) and will be addressed in a follow-up.
39
+
40
+ Known follow-up: `Intake#nested?` still reads `@raw["nested"]` to
41
+ preserve the `kind: intake, nested: true` YAML overlay used by nested
42
+ intake handlers. This dual discriminator (`kind:` + `nested:`) is a
43
+ design tension worth revisiting alongside the broader is_a? cleanup.
44
+
45
+ ## 0.21.1 — 2026-05-27
46
+
47
+ ### Fixed
48
+ - **Intake entries can now act as builder outputs.** Two related gaps closed:
49
+ - `FormatMatrix` validator no longer rejects `kind: intake` entries in
50
+ generator zones for missing a template. Intake bodies come from a
51
+ `:resolve_intake` handler, so the "derived format requires template"
52
+ rule never applied. (Error message widened from "derived #{format}"
53
+ to "#{format} entries in a generator zone require a template".)
54
+ - `Manifest::Entry::Intake` now parses `publish_to:` from YAML (was
55
+ hardcoded to `[]`).
56
+ - `textus publish` / `textus build` now fan out intake bodies to each
57
+ `publish_to` target, mirroring the Leaf fan-out path. Refresh-time
58
+ fan-out is unchanged — bodies still publish on the next publish/build
59
+ run.
60
+
61
+ Closes #80. Lets consumers replace `kind: derived, compute: { kind:
62
+ external }` runner glue with `kind: intake` + `Textus.on(:resolve_intake)`
63
+ hooks for builder-produced outputs.
64
+
65
+ ## 0.21.0 — 2026-05-27
66
+
67
+ ### BREAKING
68
+ - `textus intro` is removed. Use `textus boot` instead — same envelope, same
69
+ use case, better name (pairs with the new `pulse` verb to form the agent
70
+ lifecycle: `boot` for static contract, `pulse` for dynamic state).
71
+ - The `Textus::Intro` module is now `Textus::Boot`. The manifest entry field
72
+ `inject_intro:` is now `inject_boot:`. Builder template variable
73
+ `{{intro.*}}` is now `{{boot.*}}`. Pre-1.0; no compatibility alias.
74
+
75
+ ### Added
76
+ - **`textus pulse [--since=N]`** — agent heartbeat verb. Returns an envelope
77
+ with `cursor` (current `latest_seq`), `changed` (audit rows since N),
78
+ `stale` (entries past refresh policy), `pending_review` (keys in review
79
+ zone), and `doctor` (ok/warn/fail counts). One round-trip replaces what
80
+ was previously four separate verbs.
81
+ - **`agent_quickstart` block in `textus boot`** — names the read verbs,
82
+ write verbs, writable zones, default propose zone, and current
83
+ `latest_seq` (the starting cursor for `pulse`). Lets an agent boot once
84
+ and immediately know how to talk and where to start polling.
85
+ - **Audit log rotation.** Active `audit.log` rotates to `audit.log.1` when
86
+ it exceeds `audit.max_size` (default 10MB), keeping the last
87
+ `audit.keep` files (default 5). Each rotated file has a sidecar
88
+ `audit.log.N.meta.json` with `min_seq`/`max_seq`/`rotated_at`. Configure
89
+ via the new top-level `audit:` block in `manifest.yaml`.
90
+ - **Monotonic `seq` on every audit row.** Foundation for cursor-based
91
+ queries; `audit --seq-since=N` and `pulse --since=N` both use it.
92
+ - **`Textus::CursorExpired`** error class, raised by `pulse` and
93
+ `audit --seq-since` when the requested seq has rotated off disk. The
94
+ message names the oldest still-available seq and tells the agent to
95
+ re-orient via `textus boot`.
96
+ - `docs/agent-integration.md` — boot → pulse → work loop reference, with
97
+ an example agent loop and cursor-expiry handling.
98
+
99
+ ### Changed
100
+ - Audit rows now include a `seq` integer field (existing fields unchanged).
101
+ - `textus boot` envelope gains `agent_quickstart` (additive — existing
102
+ consumers unaffected).
103
+
104
+ ## 0.20.2 — 2026-05-27
105
+
106
+ ### Fixed
107
+ - Promotion predicate `accept_authority_signed` now checks the role's *kind*
108
+ via `manifest.role_kind`, so manifests with a renamed authority role (e.g.
109
+ `owner` instead of `human`) pass the promotion gate. The internal class
110
+ `Predicates::HumanAccept` was renamed to `Predicates::AcceptAuthoritySigned`.
111
+ - `textus schema migrate` now writes as the manifest's declared
112
+ `accept_authority` role instead of the literal `"human"`, and raises a
113
+ clear `UsageError` (with a YAML hint) when no `accept_authority` role is
114
+ declared.
115
+ - `textus accept` / `textus reject` no longer claim "only human role can
116
+ accept" when the manifest declares zero `accept_authority` roles — the
117
+ error now says "no role with accept_authority kind is declared in this
118
+ manifest; accept/reject is disabled".
119
+ - `textus build` now resolves the build role from the manifest's declared
120
+ `generator` kind instead of hardcoding `"builder"`, so renamed generator
121
+ roles work correctly.
122
+ - Manifest validator's "exactly one accept_authority" error message now
123
+ matches what the schema actually enforces.
124
+
125
+ ### Removed
126
+ - Legacy `human_accept` promotion-predicate alias (string and symbol forms).
127
+ Manifests using `rules[].promotion.requires: [human_accept]` must change
128
+ to `[accept_authority_signed]`. The error on the old form is actionable:
129
+ `unknown promotion predicate: 'human_accept' (known: schema_valid,
130
+ accept_authority_signed)`.
131
+ - `textus key normalize` verb and the underlying
132
+ `Textus::Application::Tools::MigrateKeys` module. Files dropped into nested
133
+ zones with illegal basenames are still reported by `textus doctor` with a
134
+ `key.illegal` finding; fix them by hand. The `--upgrade-manifest` flag and
135
+ its `Textus::Application::Tools::MigrateManifestToKinds` module (one-shot
136
+ 0.19→0.20 manifest upgrader) are removed for the same reason — dead weight.
137
+ - The `migrate-keys` audit-log payload string is no longer emitted (no writer
138
+ produces it).
139
+
140
+ ### Internal
141
+ - Final cleanup of role-name leaks identified by the 0.20.2 architecture
142
+ audit (follow-on to 0.20.1 role-kinds refactor).
143
+
144
+ ## 0.20.1 — 2026-05-27
145
+
146
+ ### Added
147
+ - Optional `roles:` block in `manifest.yaml` lets users rename roles without
148
+ breaking engine semantics. Each declared role maps to one of four engine
149
+ kinds: `accept_authority`, `generator`, `proposer`, `runner`. (#72)
150
+ - `Manifest#role_kind`, `Manifest#roles_with_kind`, `Manifest#zone_kinds`
151
+ accessors for engine integrations.
152
+
153
+ ### Changed
154
+ - `accept` / `reject` now gate on `accept_authority` kind, not the literal
155
+ `"human"` role. Error messages cite the configured role name.
156
+ - `validator` last-writer trust check uses `accept_authority` kind.
157
+ - Entry `in_generator_zone?` / `in_proposal_zone?` query `zone_kinds`.
158
+ - `Intro` derives `write_flows` and `agent_protocol.role_resolution.roles`
159
+ from the manifest's role mapping.
160
+ - Promote DSL predicate `:human_accept` renamed to `:accept_authority_signed`;
161
+ the old symbol still works as an alias.
162
+ - Schema rejects zone writers that reference an undeclared role when `roles:`
163
+ is declared.
164
+
165
+ ### Compatibility
166
+ - No wire protocol change (`textus/3`).
167
+ - Existing manifests without a `roles:` block behave identically to 0.20.0.
168
+
12
169
  ## 0.20.0 — architecture redesign (2026-05-27)
13
170
 
14
171
  **BREAKING (pre-1.0):** Public top-level utility modules removed,
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
 
@@ -83,7 +83,8 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
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
 
@@ -229,6 +229,40 @@ Unknown role values are rejected with `invalid_role`.
229
229
 
230
230
  Every successful write records the resolved role and a wall-clock timestamp in `.textus/audit.log`, so reviewers can later distinguish a human edit from an agent edit even though both live in the same file.
231
231
 
232
+ #### 5.1.1 Role kinds (engine semantics)
233
+
234
+ Internally the engine recognizes four **role kinds** — abstract capability
235
+ markers — rather than the four default role names. A manifest may declare a
236
+ `roles:` block to map any role name to a kind:
237
+
238
+ ```yaml
239
+ roles:
240
+ - { name: owner, kind: accept_authority }
241
+ - { name: compiler, kind: generator }
242
+ - { name: proposer, kind: proposer }
243
+ - { name: fetcher, kind: runner }
244
+ ```
245
+
246
+ Kind allow-list: `accept_authority`, `generator`, `proposer`, `runner`.
247
+ At most one role may have `accept_authority`. When `roles:` is declared,
248
+ every entry in `zones[*].write_policy` must be a declared role name.
249
+
250
+ When the `roles:` block is omitted, the default mapping applies:
251
+
252
+ | Default name | Kind |
253
+ |---|---|
254
+ | `human` | `accept_authority` |
255
+ | `agent` | `proposer` |
256
+ | `builder` | `generator` |
257
+ | `runner` | `runner` |
258
+
259
+ This means existing manifests continue to work byte-for-byte. Wire protocol
260
+ `textus/3` is unchanged — kinds are an internal-semantics concept and never
261
+ appear on the wire.
262
+
263
+ The promotion DSL predicate `:human_accept` is now `:accept_authority_signed`;
264
+ the old symbol works as an alias for backwards compatibility.
265
+
232
266
  ### 5.2 Compute layer (derived entries)
233
267
 
234
268
  Derived entries live in a zone whose `write_policy:` list includes `builder` — `output` in the default scaffold. They are not authored by hand; their body is produced by projecting over other entries. A derived entry declares a `compute:` block with a `kind:` discriminator.
@@ -396,10 +430,12 @@ Every successful write appends one compact JSON object (NDJSON) to `.textus/audi
396
430
  Schema (one JSON object per line, no interior whitespace):
397
431
 
398
432
  ```json
399
- {"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>}
400
434
  ```
401
435
 
402
- `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`, `migrate-keys`, `mv`, ...). Note that `migrate-keys` here is the on-disk payload key — the CLI surface is `textus key migrate`; the payload string is retained for log stability. `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). `key migrate --write` emits one line per renamed file (with payload `verb: "migrate-keys"`) using the new key as `key` and the file's pre- and post-rename etags.
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
+
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).
403
439
 
404
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.
405
441
 
@@ -695,7 +731,8 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
695
731
  | `hook list` | read | any |
696
732
  | `hook run NAME` | write | any |
697
733
  | `doctor [--check=NAME[,NAME]] [--output=json]` | read | any |
698
- | `intro [--output=json]` | read | any |
734
+ | `boot [--output=json]` | read | any |
735
+ | `pulse [--since=N]` | read | any |
699
736
  | `put K --stdin --as=R [--fetch=NAME]` | write | per zone |
700
737
  | `delete K --if-etag=E --as=R` | write | per zone |
701
738
  | `refresh KEY --as=runner` | write | per zone (typically `runner`) |
@@ -705,9 +742,38 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
705
742
  | `init` | write | `human` |
706
743
  | `schema {show,init,diff,migrate}` | read/write | `human` for writes |
707
744
  | `key mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
708
- | `key normalize [--dry-run\|--write]` | write (with `--write`) | `human` |
709
745
  | `key uid K` | read | any |
710
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
+
711
777
  **`put` input** (read from stdin when `--stdin` is given):
712
778
 
713
779
  ```json
@@ -765,6 +831,12 @@ Every `Textus::Error` exposes `code`, `message`, and an optional `hint:`. The hi
765
831
 
766
832
  The reference Ruby gem follows semver independently and speaks `textus/3`.
767
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
+
768
840
  ## 12. Conformance fixtures
769
841
 
770
842
  A conformant implementation MUST pass these fixtures (the reference test suite ships a YAML file listing inputs and expected envelopes):
@@ -0,0 +1,33 @@
1
+ module Textus
2
+ module Application
3
+ module Policy
4
+ module Predicates
5
+ # Promotion predicate: the role driving the promotion must have
6
+ # role_kind == :accept_authority in the active manifest.
7
+ #
8
+ # Accept/Reject already gate on this kind before reaching the
9
+ # promotion policy, so in the default control-flow this predicate
10
+ # trivially passes. It is kept so manifests can express the
11
+ # requirement explicitly in `rules[].promotion.requires`.
12
+ class AcceptAuthoritySigned
13
+ attr_reader :reason
14
+
15
+ def name
16
+ "accept_authority_signed"
17
+ end
18
+
19
+ def call(role:, manifest:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
20
+ role_str = role&.to_s
21
+ return true if role_str.nil? || role_str.empty?
22
+
23
+ kind = manifest.role_kind(role_str)
24
+ return true if kind == :accept_authority
25
+
26
+ @reason = "role '#{role_str}' has kind '#{kind.inspect}', expected ':accept_authority'"
27
+ false
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,19 +1,15 @@
1
+ require_relative "predicates/schema_valid"
2
+ require_relative "predicates/accept_authority_signed"
3
+
1
4
  module Textus
2
5
  module Application
3
6
  module Policy
4
- # Promotion evaluates a list of named predicates against a pending-proposal
5
- # entry and returns a Result indicating whether all requirements are met.
6
- #
7
- # Lives in Application because the predicates it wires up read live state
8
- # from explicit ports (schemas, manifest, role). The Domain-side rule
9
- # statement ("this policy requires predicates X and Y") is captured by
10
- # Textus::Domain::Policy::Promote.
11
7
  class Promotion
12
8
  Result = Struct.new(:ok?, :reasons, keyword_init: true)
13
9
 
14
10
  REGISTRY = {
15
11
  "schema_valid" => -> { Predicates::SchemaValid.new },
16
- "human_accept" => -> { Predicates::HumanAccept.new },
12
+ "accept_authority_signed" => -> { Predicates::AcceptAuthoritySigned.new },
17
13
  }.freeze
18
14
 
19
15
  def self.from_names(names)
@@ -49,10 +45,9 @@ module Textus
49
45
 
50
46
  def invoke(pred, entry:, schemas:, manifest:, role:)
51
47
  case pred.name
52
- when "human_accept"
53
- pred.call(role: role, entry: entry)
48
+ when "accept_authority_signed"
49
+ pred.call(role: role, manifest: manifest, entry: entry)
54
50
  else
55
- # Default shape: schema-style predicates that need entry + schemas + manifest.
56
51
  pred.call(entry: entry, schemas: schemas, manifest: manifest)
57
52
  end
58
53
  end
@@ -8,27 +8,36 @@ module Textus
8
8
  # correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
9
9
  # rows produce nil and are skipped).
10
10
  class Audit
11
- def initialize(manifest:, root:)
12
- @manifest = manifest
13
- @log_path = File.join(root, "audit.log")
11
+ def initialize(manifest:, root:, audit_log: nil)
12
+ @manifest = manifest
13
+ @root = root
14
+ @log_path = File.join(root, "audit.log")
15
+ @audit_log = audit_log
14
16
  end
15
17
 
16
18
  # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
17
- def call(key: nil, zone: nil, role: nil, verb: nil, since: nil, correlation_id: nil, limit: nil)
18
- return [] unless File.exist?(@log_path)
19
+ def call(key: nil, zone: nil, role: nil, verb: nil, since: nil, seq_since: nil, correlation_id: nil, limit: nil)
20
+ check_cursor_expiry!(seq_since)
21
+
22
+ files = all_log_files
23
+ return [] if files.empty?
19
24
 
20
25
  rows = []
21
- File.foreach(@log_path) do |line|
22
- parsed = parse_row(line.chomp)
23
- next unless parsed
24
- next if key && parsed["key"] != key
25
- next if role && parsed["role"] != role
26
- next if verb && parsed["verb"] != verb
27
- next if zone && !key_in_zone?(parsed["key"], zone)
28
- next if since && (parsed["ts"].nil? || Time.parse(parsed["ts"]) < since)
29
- next if correlation_id && parsed.dig("extras", "correlation_id") != correlation_id
26
+ files.each do |file|
27
+ File.foreach(file) do |line|
28
+ parsed = parse_row(line.chomp)
29
+ next unless parsed
30
+ next if key && parsed["key"] != key
31
+ next if role && parsed["role"] != role
32
+ next if verb && parsed["verb"] != verb
33
+ next if zone && !key_in_zone?(parsed["key"], zone)
34
+ next if since && (parsed["ts"].nil? || Time.parse(parsed["ts"]) < since)
35
+ next if seq_since && (parsed["seq"].nil? || parsed["seq"] <= seq_since)
36
+ next if correlation_id && parsed.dig("extras", "correlation_id") != correlation_id
30
37
 
31
- rows << parsed
38
+ rows << parsed
39
+ break if limit && rows.length >= limit
40
+ end
32
41
  break if limit && rows.length >= limit
33
42
  end
34
43
  rows
@@ -48,6 +57,22 @@ module Textus
48
57
 
49
58
  private
50
59
 
60
+ def check_cursor_expiry!(seq_since)
61
+ return unless seq_since
62
+
63
+ log = @audit_log || Textus::Infra::AuditLog.new(@root)
64
+ min = log.min_available_seq
65
+ raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
66
+ end
67
+
68
+ def all_log_files
69
+ rotated = Dir.glob(File.join(@root, "audit.log.*"))
70
+ .reject { |p| p.end_with?(".meta.json") }
71
+ .sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
72
+ active = File.exist?(@log_path) ? [@log_path] : []
73
+ rotated + active
74
+ end
75
+
51
76
  def parse_row(line)
52
77
  return nil if line.empty?
53
78
  return nil unless line.start_with?("{")
@@ -0,0 +1,63 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ # Aggregator over audit + freshness + review + doctor. One round-trip
5
+ # for an agent's per-turn heartbeat. All component reads are existing
6
+ # APIs; pulse is sugar with a stable envelope shape and a monotonic
7
+ # cursor (seq).
8
+ class Pulse
9
+ def initialize(ctx:, manifest:, file_store:, audit_log:, root:, store:)
10
+ @ctx = ctx
11
+ @manifest = manifest
12
+ @file_store = file_store
13
+ @audit_log = audit_log
14
+ @root = root
15
+ @store = store
16
+ end
17
+
18
+ def call(since: 0)
19
+ changed = audit_changes_since(since)
20
+ {
21
+ "cursor" => @audit_log.latest_seq,
22
+ "changed" => changed,
23
+ "stale" => stale_keys,
24
+ "pending_review" => review_keys,
25
+ "doctor" => doctor_summary,
26
+ }
27
+ end
28
+
29
+ private
30
+
31
+ def audit_changes_since(seq)
32
+ Reads::Audit.new(manifest: @manifest, root: @root, audit_log: @audit_log)
33
+ .call(seq_since: seq)
34
+ end
35
+
36
+ def stale_keys
37
+ # Freshness rows use symbol keys: { key: "x.y", status: :stale, ... }
38
+ rows = Reads::Freshness.new(ctx: @ctx, manifest: @manifest, file_store: @file_store).call
39
+ rows.select { |r| r[:status] == :stale }.map { |r| r[:key] }
40
+ end
41
+
42
+ def review_keys
43
+ # List constructor takes only manifest:; returns hashes with string keys.
44
+ # Guard: zones is a Hash keyed by name string.
45
+ return [] unless @manifest.zones.key?("review")
46
+
47
+ rows = Reads::List.new(manifest: @manifest).call(zone: "review")
48
+ rows.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
49
+ end
50
+
51
+ def doctor_summary
52
+ result = Textus::Doctor.run(@store)
53
+ issues = result["issues"] || []
54
+ {
55
+ "ok" => result["ok"],
56
+ "warn" => issues.count { |i| i["level"] == "warning" },
57
+ "fail" => issues.count { |i| i["level"] == "error" },
58
+ }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -55,9 +55,11 @@ module Textus
55
55
  last_writer = @audit_log.last_writer_for(key)
56
56
  return if last_writer.nil?
57
57
 
58
+ last_writer_is_authority = @manifest.role_kind(last_writer) == :accept_authority
59
+
58
60
  env.meta.each_key do |field|
59
61
  owner = schema.maintained_by(field)
60
- next if owner.nil? || last_writer == owner || last_writer == "human"
62
+ next if owner.nil? || last_writer == owner || last_writer_is_authority
61
63
 
62
64
  violations << { "key" => key, "code" => "role_authority",
63
65
  "field" => field, "expected" => owner, "last_writer" => last_writer }
@@ -1,7 +1,11 @@
1
+ require_relative "authority_gate"
2
+
1
3
  module Textus
2
4
  module Application
3
5
  module Writes
4
6
  class Accept
7
+ include AuthorityGate
8
+
5
9
  def initialize(ctx:, manifest:, file_store:, schemas:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
6
10
  @ctx = ctx
7
11
  @manifest = manifest
@@ -14,7 +18,7 @@ module Textus
14
18
  end
15
19
 
16
20
  def call(pending_key)
17
- raise ProposalError.new("only human role can accept proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
21
+ assert_accept_authority!("accept")
18
22
 
19
23
  env = Textus::Application::Reads::Get.new(
20
24
  ctx: @ctx, manifest: @manifest, file_store: @file_store,
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Application
3
+ module Writes
4
+ # Shared gate for write verbs that require the caller to hold the
5
+ # manifest's accept_authority role. Provides one method, expressed
6
+ # as two early-returns rather than a ternary, so each failure mode
7
+ # reads on its own line.
8
+ module AuthorityGate
9
+ def assert_accept_authority!(verb)
10
+ return if @manifest.role_kind(@ctx.role) == :accept_authority
11
+
12
+ authority = @manifest.roles_with_kind(:accept_authority).first
13
+ if authority.nil?
14
+ raise ProposalError.new(
15
+ "no role with accept_authority kind is declared in this manifest; #{verb} is disabled",
16
+ )
17
+ end
18
+
19
+ raise ProposalError.new(
20
+ "only #{authority} role can #{verb} proposals; got '#{@ctx.role}'",
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -32,7 +32,7 @@ module Textus
32
32
  transform_resolver: ->(name) { @bus.rpc_callable(:transform_rows, name) },
33
33
  template_loader: ->(name) { read_template(name) },
34
34
  transform_context: @store,
35
- inject_intro: -> { Textus::Intro.run(@store) },
35
+ inject_boot: -> { Textus::Boot.run(@store) },
36
36
  )
37
37
  end
38
38