textus 0.20.2 → 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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/README.md +7 -4
  4. data/SPEC.md +42 -3
  5. data/lib/textus/application/reads/audit.rb +40 -15
  6. data/lib/textus/application/reads/pulse.rb +63 -0
  7. data/lib/textus/application/writes/materializer.rb +1 -1
  8. data/lib/textus/application/writes/publish.rb +25 -106
  9. data/lib/textus/{intro.rb → boot.rb} +27 -5
  10. data/lib/textus/builder/pipeline.rb +2 -2
  11. data/lib/textus/cli/verb/audit.rb +2 -0
  12. data/lib/textus/cli/verb/{intro.rb → boot.rb} +3 -3
  13. data/lib/textus/cli/verb/pulse.rb +17 -0
  14. data/lib/textus/cli.rb +1 -1
  15. data/lib/textus/errors.rb +16 -0
  16. data/lib/textus/infra/audit_log.rb +126 -16
  17. data/lib/textus/manifest/entry/base.rb +41 -4
  18. data/lib/textus/manifest/entry/derived.rb +40 -4
  19. data/lib/textus/manifest/entry/intake.rb +15 -3
  20. data/lib/textus/manifest/entry/leaf.rb +6 -5
  21. data/lib/textus/manifest/entry/nested.rb +42 -3
  22. data/lib/textus/manifest/entry/parser.rb +8 -44
  23. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  24. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
  25. data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
  26. data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
  27. data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
  28. data/lib/textus/manifest/entry/validators.rb +1 -1
  29. data/lib/textus/manifest/entry.rb +3 -0
  30. data/lib/textus/manifest/resolver.rb +4 -4
  31. data/lib/textus/manifest/schema.rb +20 -6
  32. data/lib/textus/manifest.rb +10 -0
  33. data/lib/textus/operations.rb +8 -1
  34. data/lib/textus/store.rb +5 -1
  35. data/lib/textus/version.rb +1 -1
  36. metadata +6 -4
  37. data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 35d3b6540fc043048133df0874e76b36e522d844da783b69a5e8993418157ae4
4
- data.tar.gz: e0293b87efb32c1edf2d6c0103dcd94289d91031a4be570f8fdd40fb681c1e79
3
+ metadata.gz: 38a9a929bb63f94d5a3cdc4708f09ce5c8e0eca28928d5733b8d842d90ece8b8
4
+ data.tar.gz: 26a8aeb22666788cd30e949eb25c7336db1de9ef7f4110aaf700f7abecdec644
5
5
  SHA512:
6
- metadata.gz: '039b6f44941ea52ac8d956297d9619c4cc8b2346e7d8bced24bc5671506726a44348da66791aa6d032e56dd403353d1b34e9b2fee37eaec05cd6c83d5defc715'
7
- data.tar.gz: e6e5e8f97f5f9a07a03813d61f9efa2088fb8f1338af89ba1298bf307c6c72e89f1028aa5a67ffdf8f97f2151c0af1273f82ff80751c59c11aa93ef2953323a9
6
+ metadata.gz: 9b4ce0da2828f2623f60601cdd0ac8a69e51cc9670c5de80b92e3b41e0f955361154664b8cb0a7de0e838b5403336ff26589698201885b44001a83cfcd2c3f21
7
+ data.tar.gz: 672d948ab0ccdea1470dcb38c87c233ed717a538d4e4902191a32224dac8475fe1269b4569be00e5eb1f40e9c6146f8867d557d6455601c13fc2e0ce2c381cae
data/CHANGELOG.md CHANGED
@@ -9,6 +9,98 @@ 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
+
12
104
  ## 0.20.2 — 2026-05-27
13
105
 
14
106
  ### 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
 
@@ -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
 
@@ -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):
@@ -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
@@ -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
 
@@ -1,13 +1,14 @@
1
1
  module Textus
2
2
  module Application
3
3
  module Writes
4
- # Single-pass publish use case: materializes Derived entries (template +
5
- # projection + external runner) AND copies Leaf/Nested entries to their
6
- # publish targets. Replaces the former two-step Build + Publish split.
4
+ # Single-pass publish use case: dispatches polymorphically to each
5
+ # entry's `publish_via` method. Derived entries materialize their body
6
+ # via Materializer; Nested entries fan out via publish_each; Leaf and
7
+ # Intake entries copy their stored body to publish_to targets. The
8
+ # Publish layer owns wiring (context, accumulation) but not per-kind
9
+ # logic.
7
10
  #
8
11
  # Return shape: { "protocol", "built", "published_leaves" }
9
- # — wire-compatible with what the `textus build` CLI verb previously
10
- # assembled by merging Build + old Publish results.
11
12
  class Publish
12
13
  def initialize(ctx:, manifest:, file_store:, bus:, root:, store:, hook_context:) # rubocop:disable Metrics/ParameterLists
13
14
  @ctx = ctx
@@ -22,26 +23,17 @@ module Textus
22
23
  def call(prefix: nil)
23
24
  built = []
24
25
  leaves = []
25
- repo_root = File.dirname(@root)
26
+ context = build_context
26
27
 
27
28
  @manifest.entries.each do |mentry|
28
29
  next if prefix && !entry_matches_prefix?(mentry, prefix)
29
30
 
30
- case mentry
31
- when Textus::Manifest::Entry::Derived
32
- next unless mentry.in_generator_zone?
31
+ result = mentry.publish_via(context, prefix: prefix)
32
+ next if result.nil?
33
33
 
34
- result = materialize_derived(mentry, repo_root)
35
- built << result if result
36
- when Textus::Manifest::Entry::Nested
37
- next unless mentry.publish_each
38
-
39
- publish_nested(mentry, repo_root, prefix, leaves)
40
- when Textus::Manifest::Entry::Leaf
41
- next if Array(mentry.publish_to).empty?
42
-
43
- result = publish_leaf_entry(mentry, repo_root)
44
- built << result if result
34
+ case result[:kind]
35
+ when :built then built << result[:value]
36
+ when :leaves then leaves.concat(result[:value])
45
37
  end
46
38
  end
47
39
 
@@ -50,86 +42,19 @@ module Textus
50
42
 
51
43
  private
52
44
 
53
- # Materialize a Derived entry and copy to publish_to targets.
54
- def materialize_derived(mentry, repo_root)
55
- target_path = Materializer.new(
56
- ctx: @ctx, manifest: @manifest, file_store: @file_store,
57
- bus: @bus, root: @root, store: @store
58
- ).run(mentry)
59
-
60
- publish_derived_copies(mentry, target_path, repo_root)
61
- fire_build_completed(mentry)
62
-
63
- { "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
64
- end
65
-
66
- def publish_derived_copies(mentry, target_path, repo_root)
67
- envelope = reader.call(mentry.key)
68
- mentry.publish_to.each do |rel|
69
- target_abs = File.join(repo_root, rel)
70
- Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: @root)
71
- publish_event(:file_published,
72
- key: mentry.key,
73
- envelope: envelope,
74
- source: target_path,
75
- target: target_abs)
76
- end
77
- end
78
-
79
- def fire_build_completed(mentry)
80
- envelope = reader.call(mentry.key)
81
- src = mentry.source
82
- selects = src.is_a?(Textus::Manifest::Entry::Derived::Projection) ? Array(src.select).compact : []
83
- publish_event(:build_completed,
84
- key: mentry.key,
85
- envelope: envelope,
86
- sources: selects)
87
- end
88
-
89
- # Publish each leaf under a Nested entry's publish_each pattern.
90
- def publish_nested(mentry, repo_root, prefix, accumulator)
91
- @manifest.resolver.enumerate(prefix: mentry.key).each do |row|
92
- next unless row[:manifest_entry].equal?(mentry)
93
- next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
94
-
95
- accumulator << publish_nested_leaf(mentry, row, repo_root)
96
- end
97
- end
98
-
99
- def publish_nested_leaf(mentry, row, repo_root)
100
- target_rel = mentry.publish_target_for(row[:key])
101
- target_abs = File.expand_path(File.join(repo_root, target_rel))
102
- unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
103
- raise PublishError.new(
104
- "entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
105
- )
106
- end
107
-
108
- Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: @root)
109
- publish_event(:file_published,
110
- key: row[:key],
111
- envelope: reader.call(row[:key]),
112
- source: row[:path],
113
- target: target_abs)
114
- { "key" => row[:key], "source" => row[:path], "target" => target_abs }
115
- end
116
-
117
- # Publish a standalone Leaf entry that has publish_to targets.
118
- def publish_leaf_entry(mentry, repo_root)
119
- source_path = @manifest.resolver.resolve(mentry.key).path
120
- envelope = reader.call(mentry.key)
121
-
122
- mentry.publish_to.each do |rel|
123
- target_abs = File.join(repo_root, rel)
124
- Textus::Infra::Publisher.publish(source: source_path, target: target_abs, store_root: @root)
125
- publish_event(:file_published,
126
- key: mentry.key,
127
- envelope: envelope,
128
- source: source_path,
129
- target: target_abs)
130
- end
131
-
132
- { "key" => mentry.key, "path" => source_path, "published_to" => mentry.publish_to }
45
+ def build_context
46
+ Textus::Manifest::Entry::Base::PublishContext.new(
47
+ repo_root: File.dirname(@root),
48
+ manifest: @manifest,
49
+ file_store: @file_store,
50
+ root: @root,
51
+ store: @store,
52
+ ctx: @ctx,
53
+ bus: @bus,
54
+ hook_context: @hook_context,
55
+ reader: reader,
56
+ emit: ->(event, **payload) { @bus.publish(event, ctx: @hook_context, **payload) },
57
+ )
133
58
  end
134
59
 
135
60
  # Whether the entry should be processed for the given prefix filter.
@@ -138,8 +63,6 @@ module Textus
138
63
 
139
64
  case mentry
140
65
  when Textus::Manifest::Entry::Nested
141
- # Nested: process if the entry key is a prefix of `prefix` or
142
- # `prefix` is a prefix of the entry key (a leaf under it).
143
66
  mentry.key.start_with?(prefix) ||
144
67
  prefix.start_with?("#{mentry.key}.")
145
68
  else
@@ -152,10 +75,6 @@ module Textus
152
75
  ctx: @ctx, manifest: @manifest, file_store: @file_store,
153
76
  )
154
77
  end
155
-
156
- def publish_event(event, **payload)
157
- @bus.publish(event, ctx: @hook_context, **payload)
158
- end
159
78
  end
160
79
  end
161
80
  end
@@ -4,8 +4,8 @@ module Textus
4
4
  # project: zones and their write authority, entries and their flags,
5
5
  # registered hooks, write flows, and the CLI verb catalog.
6
6
  #
7
- # Intro is side-effect-free.
8
- module Intro
7
+ # Boot is side-effect-free.
8
+ module Boot
9
9
  PROTOCOL_ID = PROTOCOL
10
10
 
11
11
  # Conventional zone purposes. Unknown zones (declared in the manifest
@@ -95,10 +95,10 @@ module Textus
95
95
  }.freeze
96
96
 
97
97
  # The CLI verb catalog. Truth lives here; do not derive dynamically.
98
- # Agents that read intro should see a stable shape regardless of how
98
+ # Agents that read boot should see a stable shape regardless of how
99
99
  # verb implementations evolve.
100
100
  CLI_VERBS = [
101
- { "name" => "intro", "summary" => "this output — orientation for agents and tools" },
101
+ { "name" => "boot", "summary" => "this output — orientation for agents and tools" },
102
102
  { "name" => "list", "summary" => "enumerate keys (optional --prefix)" },
103
103
  { "name" => "get", "summary" => "read an entry; envelope with _meta, body, uid, etag" },
104
104
  { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
@@ -116,8 +116,29 @@ module Textus
116
116
  { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
117
117
  { "name" => "hook",
118
118
  "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
119
+ { "name" => "pulse",
120
+ "summary" => "delta since cursor — changed entries, stale, pending review, doctor summary" },
119
121
  ].freeze
120
122
 
123
+ def self.agent_quickstart(manifest, store)
124
+ proposer_roles = manifest.roles_with_kind(:proposer)
125
+ agent_role = proposer_roles.first
126
+
127
+ writable_zones = manifest.zones.each_with_object([]) do |(zname, writers), acc|
128
+ acc << zname if agent_role && writers.include?(agent_role)
129
+ end
130
+
131
+ propose_zone = writable_zones.find { |z| z.include?("review") } || writable_zones.first
132
+
133
+ {
134
+ "read_verbs" => %w[boot get list audit pulse freshness doctor],
135
+ "write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
136
+ "writable_zones" => writable_zones,
137
+ "propose_zone" => propose_zone,
138
+ "latest_seq" => store.audit_log.latest_seq,
139
+ }
140
+ end
141
+
121
142
  def self.agent_protocol(manifest)
122
143
  AGENT_PROTOCOL_TEMPLATE.merge(
123
144
  "role_resolution" => {
@@ -139,6 +160,7 @@ module Textus
139
160
  "write_flows" => write_flows_for(store.manifest),
140
161
  "cli_verbs" => CLI_VERBS.map(&:dup),
141
162
  "agent_protocol" => agent_protocol(store.manifest),
163
+ "agent_quickstart" => agent_quickstart(store.manifest, store),
142
164
  "docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
143
165
  }
144
166
  end
@@ -165,7 +187,7 @@ module Textus
165
187
  "derived" => derived,
166
188
  "intake" => e.is_a?(Textus::Manifest::Entry::Intake),
167
189
  "publish_to" => Array(e.publish_to),
168
- "publish_each" => e.respond_to?(:publish_each) ? e.publish_each : nil,
190
+ "publish_each" => e.publish_each,
169
191
  }
170
192
  end
171
193
  end
@@ -64,7 +64,7 @@ module Textus
64
64
 
65
65
  # rubocop:disable Metrics/ParameterLists
66
66
  def self.run(mentry:, manifest:, reader:, lister:, transform_resolver:, template_loader:,
67
- transform_context: nil, inject_intro: nil)
67
+ transform_context: nil, inject_boot: nil)
68
68
  # 1. Load sources + project + reduce
69
69
  data =
70
70
  if mentry.is_a?(Textus::Manifest::Entry::Derived) && mentry.projection?
@@ -78,7 +78,7 @@ module Textus
78
78
  else
79
79
  { "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
80
80
  end
81
- data = data.merge("intro" => inject_intro.call) if mentry.inject_intro && inject_intro
81
+ data = data.merge("boot" => inject_boot.call) if mentry.inject_boot && inject_boot
82
82
 
83
83
  # 2. Render
84
84
  klass = renderers[mentry.format] or