textus 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 198dc9a4561b79bf4da22a2f890caa5da2764b8d82b1665b506d6c4c8c0e3fe3
4
- data.tar.gz: dc5333c3605b7b05174f4b290fcb63260aad7ea089da900f0b3325cf3823d83c
3
+ metadata.gz: da7bc5c1fbc90ea76df8adb279491b3a94b4b093f014281ca28c317366539cad
4
+ data.tar.gz: 92b35dab2a95f81d93e45a467652ee840ed06617a3df587d66a170884b99b81f
5
5
  SHA512:
6
- metadata.gz: f9e08a7a3fc46732dcd9458114765a54e3f8d2aca08b3ead6b70ecfc2782c0e9fec5eec38fc933cef9582c5943db52973c8bb06e544c02afec468dc50bbc8797
7
- data.tar.gz: 9cb891ae4ce1d7583af7546e16e1f1a23d8d88af105430d31c2eb3fd4e25af6df2a60c5677c810a3d11f01582d7500a18d681b8996e9f2f5da1948e32ec2a39f
6
+ metadata.gz: f84bf665d8aa002bfac744ada1e43b6993d9209f8997cafdfc441f74c758edb8ef28b5b3d45e70f8ccb58cc3c82c05a3238a40940142a35d25fd1807ed237324
7
+ data.tar.gz: cd2326ee626d90217aaf9f9e91b10441d6b397af3bf3bb8c54dde00e5bfa37eca56ca7011175f58168a1b0365a484de76e9ebac3022f035833974442a73e9f1e
data/ARCHITECTURE.md ADDED
@@ -0,0 +1,74 @@
1
+ # Textus architecture
2
+
3
+ ```
4
+ ┌─ Interface ────────────────────────────────────────────────┐
5
+ │ CLI verbs: ctx = Composition.context(store, role:) │
6
+ │ Composition.<use_case>(ctx).call(...) │
7
+ └──────────────────────┬─────────────────────────────────────┘
8
+
9
+ ┌─ Application ────────▼─────────────────────────────────────┐
10
+ │ Context (per-request: store, role, correlation, │
11
+ │ clock, dry_run; can_read?/can_write?) │
12
+ │ Composition (factory module) │
13
+ │ │
14
+ │ reads/get.rb writes/put.rb │
15
+ │ refresh/worker.rb writes/delete.rb │
16
+ │ refresh/orchestrator.rb writes/build.rb │
17
+ │ refresh/all.rb writes/accept.rb │
18
+ │ writes/publish.rb │
19
+ └──────────┬───────────────────────────────┬─────────────────┘
20
+ │ uses domain │ uses ports
21
+ ┌─ Domain ─▼─────────────────────────────────────────────────┐
22
+ │ Permission ← NEW: predicate, not Action │
23
+ │ Freshness::Policy Freshness::Verdict │
24
+ │ Freshness::Evaluator Action Outcome │
25
+ └──────────────────────────────────────────┬─────────────────┘
26
+ │ implements
27
+ ┌─ Infrastructure ─────────────────────────▼─────────────────┐
28
+ │ Store (pure adapter — exposes ports only) │
29
+ │ Reader#read_envelope Writer#write_envelope_… │
30
+ │ AuditLog, Staleness, Validator, Mover │
31
+ │ Manifest (incl. permission_for) │
32
+ │ Hooks::Registry EventBus Clock │
33
+ │ Refresh::Lock Refresh::Detached │
34
+ │ Infra::Publisher (file copy + sentinel) │
35
+ └────────────────────────────────────────────────────────────┘
36
+
37
+ Dependency rule: arrows point DOWN. Domain has zero outbound
38
+ imports. Application imports Domain + Infra (via ports).
39
+ ```
40
+
41
+ ## Read path (`store.get`)
42
+
43
+ 1. CLI verb (or any caller) invokes `store.get(key, as:)`.
44
+ 2. `Store#get` constructs `Reads::Get(store, orchestrator)` and calls `.call(key, as:)`.
45
+ 3. `Reads::Get#call` reads the envelope from disk via `store.reader.read_raw_envelope(key)`.
46
+ 4. Resolves `Manifest::Entry#policy` — a `Domain::Freshness::Policy` value.
47
+ 5. `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`.
48
+ 6. If fresh → annotate envelope (`stale: false`, `refreshing: false`) and return.
49
+ 7. Otherwise `policy.decide(verdict) → Action` (data, not behavior).
50
+ 8. `Orchestrator.execute(action, key, as)` interprets the Action:
51
+ - `Action::Return` → `Outcome::Skipped`
52
+ - `Action::RefreshSync` → run Worker inline → `Refreshed | Failed`
53
+ - `Action::RefreshTimed(budget_ms:)` → race Worker thread vs budget; on timeout, kill thread, fire `:refresh_detached`, fork+detach child, return `Outcome::Detached`
54
+ 9. Map outcome → envelope annotations (`stale`, `refreshing`, `refresh_error`) and return.
55
+
56
+ ## Write path (`store.put`)
57
+
58
+ 1. CLI verb calls `ctx = Composition.context(store, role:)` then `Composition.writes_put(ctx).call(key, ...)`.
59
+ 2. `Writes::Put#call` checks `ctx.can_write?(zone)` — raises `write_forbidden` if denied.
60
+ 3. Delegates pure I/O to `Store::Writer#write_envelope_to_disk(key, ...)`.
61
+ 4. On success, fires `:put` event via the injected `Infra::EventBus`, including `correlation_id` from the Context.
62
+
63
+ The same pattern applies to `Writes::Delete`, `Writes::Build`, `Writes::Accept`, and `Writes::Publish`: each takes a `Context`, checks permissions at the use-case layer, then delegates raw I/O to the corresponding `Store::Writer` or `Infra::Publisher` primitive.
64
+
65
+ ## Refresh path (`textus refresh KEY`)
66
+
67
+ 1. CLI `Verb::Refresh` calls `Textus::Refresh.call(store, key, as:)`.
68
+ 2. That shim instantiates `Application::Refresh::Worker` and runs it.
69
+ 3. `Worker#run`:
70
+ - Resolves the manifest entry, looks up the `:intake` handler.
71
+ - Publishes `:refresh_began` via the injected `Infra::EventBus`.
72
+ - Invokes the handler under a 30s `Timeout.timeout` budget.
73
+ - On any error: publishes `:refresh_failed`, then re-raises (or wraps in `UsageError`).
74
+ - On success: normalizes the return shape, persists via `store.put`, publishes `:refreshed` (unless the etag is unchanged).
data/CHANGELOG.md CHANGED
@@ -8,6 +8,24 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
8
8
  (currently `textus/2`, embedded in every envelope as `protocol`). The protocol
9
9
  is additive within a major; a new major would change the wire string.
10
10
 
11
+ ## 0.10.1 — Documentation refresh and spec hygiene (2026-05-22)
12
+
13
+ Lightweight maintenance release: documentation refresh plus spec-suite hygiene. No `lib/` changes; no CLI, wire-protocol, or behavioral changes.
14
+
15
+ ### Changed
16
+
17
+ - `docs/architecture.md` deleted. `ARCHITECTURE.md` is now the single source of truth for the layered architecture. Inbound links in `docs/zones.md`, `docs/events.md`, and `textus.gemspec` updated.
18
+ - `SPEC.md` examples and CLI snippets refer to the post-0.9.2 default zone names (`identity` / `inbox` / `review` / `output`) instead of the pre-rename `canon` / `intake` / `pending` / `derived`. Prose that explicitly explains the 0.9.2 rename — including the v0.1 back-compat manifest example and the zone-rename table — is preserved.
19
+ - `README.md` `refresh-stale` examples switched to `--zone=inbox`; the `cat .textus/zones/...` example points at the `output` zone.
20
+ - `ARCHITECTURE.md` layer diagram references `Infra::Publisher` instead of bare `Publisher`.
21
+ - `docs/zones.md` references `Textus::Infra::Publisher` instead of bare `Publisher`.
22
+
23
+ ### Testing
24
+
25
+ - Deleted redundant `spec/proposal_spec.rb` (the same `"2026-05-19-add-bob"` fixture is covered more thoroughly by `spec/application/writes/accept_spec.rb`, the canonical post-0.9.1 home for Application-layer write tests).
26
+ - Extracted `shared_context "textus_store_fixture"` into `spec/support/fixtures.rb`; 28 specs adopt it, replacing the repeated `let(:tmp)` / `let(:root)` / `after { FileUtils.remove_entry(tmp) }` triplet. `spec/spec_helper.rb` now autoloads `spec/support/**/*.rb`.
27
+ - Fixed an `instance_variable_set(:@intake_handler, nil)` anti-pattern in `spec/refresh_spec.rb` — the "no intake declared" case now uses a manifest entry that was never given an intake handler, instead of mutating a private ivar after construction.
28
+
11
29
  ## 0.10.0 — Shim removal, signal-based zone detection, Builder extraction (2026-05-22)
12
30
 
13
31
  ### Breaking — Ruby API
data/README.md CHANGED
@@ -77,7 +77,7 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
77
77
 
78
78
  ## What ships today
79
79
 
80
- - **Per-entry formats.** `format: markdown | json | yaml | text` on a manifest entry. `cat .textus/zones/derived/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`, `reducer`).
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`, `reducer`).
81
81
  - **Per-leaf publishing.** Nested entries declare `publish_each: "skills/{basename}/SKILL.md"`. Every leaf byte-copies to its consumer location on `textus build`. No more hand-mirrored `agents/` / `skills/` / `commands/` directories.
82
82
  - **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.
83
83
  - **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus key migrate --dry-run|--write` rewrites existing stores with illegal segments deterministically.
@@ -186,9 +186,9 @@ end
186
186
  To keep a batch of stale intake entries current in one shot:
187
187
 
188
188
  ```sh
189
- textus refresh-stale --prefix=working --zone=intake --as=script
190
- # or just refresh everything stale in the intake zone:
191
- textus refresh-stale --zone=intake --as=script
189
+ textus refresh-stale --prefix=working --zone=inbox --as=script
190
+ # or just refresh everything stale in the inbox zone:
191
+ textus refresh-stale --zone=inbox --as=script
192
192
  ```
193
193
 
194
194
  The primitive `Textus.hook(event, name, **opts) { ... }` is also supported. See SPEC.md §5.10 for the full contract.
data/SPEC.md CHANGED
@@ -23,7 +23,7 @@ textus is organized as five composable layers. Each layer has a single responsib
23
23
  | Layer | Name | Responsibility |
24
24
  |---|---|---|
25
25
  | L1 | **Store** | Plain-file backend: `.textus/zones/<zone>/...` with YAML frontmatter + Markdown body, addressed by dotted keys, schema-validated, etag-versioned. |
26
- | L2 | **Sources** | Declared external inputs (`intake` zone): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external runners fetch and pipe results through `textus put`. |
26
+ | L2 | **Sources** | Declared external inputs (the `inbox` zone in the default scaffold; any zone writable by `script`): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external runners fetch and pipe results through `textus put`. |
27
27
  | L3 | **Compute** | Pure transforms from store entries to derived entries. Projections (select/pluck/sort/limit/format) plus a vendored Mustache template subset. No shell execution. |
28
28
  | L4 | **Publish** | Byte-for-byte file copy from derived entries to repo-relative paths declared via `publish_to:`. The in-store artifact is the consumer-shaped output; the published file is an identical copy. A sentinel under `.textus/sentinels/<target-rel-path>.textus-managed.json` records the source, sha256, and `mode: "copy"`. |
29
29
  | L5 | **Consumers** | Anything that reads the published files or calls the CLI — editors, LLM tools, MCP servers, CI jobs, dashboards. textus is agnostic about who consumes; the envelope is the contract. |
@@ -213,11 +213,11 @@ Every successful write records the resolved role and a wall-clock timestamp in `
213
213
 
214
214
  ### 5.2 Compute layer (derived entries)
215
215
 
216
- Derived entries live in the `derived` zone. They are not authored by hand; their body is produced by projecting over other entries. A derived entry's frontmatter declares a `projection` block:
216
+ Derived entries live in a zone whose `writable_by:` list includes `build` — `output` in the default scaffold. They are not authored by hand; their body is produced by projecting over other entries. A derived entry's frontmatter declares a `projection` block:
217
217
 
218
218
  ```yaml
219
- - key: derived.catalogs.people
220
- zone: derived
219
+ - key: output.catalogs.people
220
+ zone: output
221
221
  projection:
222
222
  select: working.network.org # prefix OR [list of prefixes]
223
223
  pluck: [name, relationship, org]
@@ -250,7 +250,7 @@ publish_to:
250
250
  - .ai/instructions.md
251
251
  ```
252
252
 
253
- When the entry is recomputed, textus copies the in-store file byte-for-byte to each destination. The in-store artifact under `.textus/zones/derived/…` is already the consumer-shaped output (per the format strategy — see §5.x), so publish is a verbatim file copy with no parsing or stripping.
253
+ When the entry is recomputed, textus copies the in-store file byte-for-byte to each destination. The in-store artifact under `.textus/zones/<output-zone>/…` is already the consumer-shaped output (per the format strategy — see §5.x), so publish is a verbatim file copy with no parsing or stripping.
254
254
 
255
255
  A sentinel is written for each published file at `<store_root>/sentinels/<target-relative-to-repo>.textus-managed.json`, recording `source`, `target`, the target's sha256, and `mode: "copy"`. Sentinels live under the store rather than beside the consumer file so target directories stay clean. The sentinel exists so out-of-band edits can be detected on the next publish — textus refuses to clobber a destination that is not either missing or marked as managed. Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
256
256
 
@@ -301,13 +301,13 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
301
301
  **Refresh paths.** Two are supported:
302
302
 
303
303
  1. **In-process** — `textus refresh KEY --as=script` resolves the entry's `intake.handler`, invokes the registered `:intake` hook with `(config:, store:, args: {})`, and writes the result under role `script`.
304
- 2. **External runner** — a cron job or agent harness reads `textus list --zone=intake --stale --format=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=script --stdin`. The CLI verb `textus refresh-stale [--prefix=K] [--zone=Z]` drives this loop in one shot.
304
+ 2. **External runner** — a cron job or agent harness reads `textus list --zone=inbox --stale --format=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=script --stdin`. The CLI verb `textus refresh-stale [--prefix=K] [--zone=Z]` drives this loop in one shot.
305
305
 
306
306
  Both paths share the same role gate, audit-log entry, and `:refreshed` event. User-supplied hooks live in `.textus/hooks/**/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
307
307
 
308
308
  ### 5.5 Pending / accept workflow
309
309
 
310
- Pending entries are full proposal patches authored into the `pending` zone, typically by agents or scripts. A pending entry's frontmatter describes the patch it proposes against another zone:
310
+ Proposal entries are full patches authored into a zone whose `writable_by:` list includes `ai` `review` in the default scaffold — typically by agents or scripts. The entry's frontmatter describes the patch it proposes against another zone:
311
311
 
312
312
  ```yaml
313
313
  ---
@@ -324,7 +324,7 @@ Proposed body content.
324
324
 
325
325
  `proposal.target_key` names the entry the patch would create or modify, and `proposal.action` is `put` or `delete`. The remaining frontmatter and body are the proposed new content.
326
326
 
327
- `textus accept <pending-key>` is **human-only**: the resolved role must be `human`. It copies the patch into the target zone, records provenance (originating pending key, original role, original timestamp) in the audit log, and removes the pending entry. Agents and scripts can propose but cannot accept.
327
+ `textus accept <proposal-key>` is **human-only**: the resolved role must be `human`. It copies the patch into the target zone, records provenance (originating proposal key, original role, original timestamp) in the audit log, and removes the proposal entry. Agents and scripts can propose but cannot accept.
328
328
 
329
329
  ### 5.6 Audit log
330
330
 
data/docs/conventions.md CHANGED
@@ -18,7 +18,7 @@ Recommended top-level layout — the spec allows alternatives, but this is what
18
18
  manifest.yaml
19
19
  schemas/ # YAML schema definitions
20
20
  zones/
21
- identity/ # identity, voice, canon — humans only
21
+ identity/ # identity, voice, slow-changing facts — humans only
22
22
  working/ # agent-writable working memory
23
23
  inbox/ # script-fed external inputs
24
24
  review/ # AI proposals awaiting accept
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.10.0"
2
+ VERSION = "0.10.1"
3
3
  PROTOCOL = "textus/2"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -102,10 +102,10 @@ executables:
102
102
  extensions: []
103
103
  extra_rdoc_files: []
104
104
  files:
105
+ - ARCHITECTURE.md
105
106
  - CHANGELOG.md
106
107
  - README.md
107
108
  - SPEC.md
108
- - docs/architecture.md
109
109
  - docs/conventions.md
110
110
  - exe/textus
111
111
  - lib/textus.rb
data/docs/architecture.md DELETED
@@ -1,129 +0,0 @@
1
- # Architecture
2
-
3
- How the reference Ruby implementation is organized. The wire protocol itself lives in [`../SPEC.md`](../SPEC.md); this document covers *how* the gem implements that spec.
4
-
5
- The codebase is a flat graph of small modules under one CLI dispatcher, not a strict pyramid. The clusters below describe what each module exists for and which other modules it talks to.
6
-
7
- ## At a glance
8
-
9
- ```
10
- exe/textus → Textus::CLI ──┬──► Store (facade — delegates to Reader/Writer/Mover)
11
- │ ├──► Store::Reader (get/list/where/uid/deps/published/stale/validate_all)
12
- │ ├──► Store::Writer (put/delete/accept)
13
- │ ├──► Store::Mover (mv)
14
- │ └──► Hooks::Dispatcher (lifecycle publish/subscribe)
15
- ├──► Builder (build verb — Pipeline + per-format renderers)
16
- ├──► Refresh (refresh verb)
17
- ├──► Doctor (doctor verb)
18
- ├──► Init (init verb)
19
- ├──► Intro (intro verb)
20
- ├──► MigrateKeys (key migrate, key mv verbs)
21
- ├──► Schema::Tools (schema init/diff/migrate verbs)
22
- ├──► Store::View (read-only projection over Store::Reader)
23
- └──► Role (role gate)
24
- ```
25
-
26
- CLI is the single entry point. It parses argv and dispatches each verb to whichever module owns that capability — there is no single mediator below CLI.
27
-
28
- ## Module clusters
29
-
30
- ### 1. Request path — core read/write verbs
31
-
32
- `Store` is a thin facade (~110 LOC) that holds `Manifest`, `Hooks::Registry`, `Hooks::Dispatcher`, and the lazy `Store::AuditLog`, then delegates verbs to a small set of focused collaborators:
33
-
34
- - **`Store::Reader`** — owns `get`, `list`, `where`, `uid`, `deps`, `rdeps`, `published`, `schema_envelope`, `validate_all`. The only module that reads working-store entry files. (`freshness` lives in `Application::Reads::Freshness` since 0.9.2; the legacy `stale` shim was removed.)
35
- - **`Store::Writer`** — owns `put`, `delete`, `accept`. Handles serialization, uid minting, etag check, role gate, audit append, and event publication. The only module that writes working-store entry files.
36
- - **`Store::Mover`** — owns `mv` (same-zone rename) with uid preservation and one audit row.
37
- - **`Store::Validator`** / **`Store::Staleness`** — back the `validate_all` / `freshness` reads. Take explicit collaborators (`reader:`, `manifest:`, `audit_log:`, `schema_for:`) instead of the full store.
38
-
39
- Shared value modules and primitives consumed by Reader/Writer/Mover:
40
-
41
- - **`Textus::Key::Path`** — `Key::Path.resolve(manifest, mentry)` returns the absolute leaf path for a manifest entry. Single source for zone-path construction; used by `Manifest`, `Staleness`, `Builder`, and Writer.
42
- - **`Textus::Envelope`** — `Envelope.build(...)` returns the canonical envelope hash (protocol, key, zone, owner, path, format, `_meta`, body, etag, schema_ref, uid, optional content). Single source for envelope shape across `get` and `put`.
43
- - **`Manifest`** — parses `.textus/manifest.yaml`; resolves a dotted key to a path via longest-prefix match. `nested: true` entries treat unmatched suffix segments as `/`-joined subdirs, with `.md` appended. Resolution is path-only; existence is the verb's concern.
44
- - **`Schema`** — loads YAML schema files; validates frontmatter shape and surfaces unknown-key warnings (the §6 forward-compat rule).
45
- - **`Entry`** + format adapters (`entry/markdown.rb`, `entry/text.rb`, `entry/json.rb`, `entry/yaml.rb`) — splits raw bytes on `---\n`, feeds the YAML chunk to `YAML.safe_load` (no aliases, restricted classes). The frontmatter `name:` field is enforced against the file basename in Reader/Writer (on read and on write) — mismatch raises `bad_frontmatter`.
46
- - **`Etag`** — `sha256:<hex>` over raw file bytes. `put` accepts optional `if_etag:`; mismatch raises `etag_mismatch`. No locking, no temp-file-and-rename — v1 leaves stronger guarantees to v1.x.
47
- - **`Role`** — agent-vs-human gate. Writer checks `Manifest::Entry#zone_writers` before doing anything else; otherwise raises `write_forbidden`.
48
- - **`Store::AuditLog`** — append-only NDJSON; every successful write emits one line.
49
- - **`Proposal`** — `accept` verb flow for promoting a pending entry into its target zone.
50
- - **`Dependencies`** — `deps`/`rdeps`/`published` verb backing; walks manifest declarations.
51
-
52
- ### 2. Build / publish pipeline
53
-
54
- Separate from the request path. Owns derived-entry materialization and byte-copy publish. `Builder` orchestrates per-entry materialization through `Builder::Pipeline`, which runs an ordered step list and dispatches the rendering step to one of four format-specific renderers. Adding a new output format is a single-file change under `lib/textus/builder/renderer/`.
55
-
56
- ```
57
- Builder ──► Pipeline ──► LoadSources ──► Project ──► Render (per-format) ──► Write ──► Publisher ──► (sentinel)
58
-
59
- └──► Renderer::{Markdown, Text, Json, Yaml}
60
- ```
61
-
62
- - **`Builder`** — iterates `zone: derived` entries, hands each to `Pipeline.run`, then handles `Publisher` copy-out and fires the `:build` event. Holds no format-specific logic.
63
- - **`Builder::Pipeline`** — `Pipeline.run(store:, mentry:, template_loader:)` is the orchestrator: runs the projection, merges `intro` if `inject_intro: true`, dispatches to the matching renderer, writes the bytes to the derived path.
64
- - **`Builder::InjectMeta`** — builds the `_meta` block (`generated_at`, `from`, `template`, `reduce`) and threads it onto JSON/YAML content as the first key per SPEC §6 ordering.
65
- - **`Builder::Renderer::{Markdown,Text,Json,Yaml}`** — one class per format, inheriting `Builder::Renderer`. Receives a template-loader lambda and `(mentry:, data:)`; returns rendered bytes. Markdown/Text always require a template; JSON/YAML optionally accept one (otherwise default-shape the projection rows).
66
- - **`Projection`** — collects rows from manifest-declared source keys, applies optional reducer, sorts and positions. Pure data shaping.
67
- - **`Mustache`** — minimal mustache renderer for templates in `.textus/templates/`.
68
- - **`Publisher`** — byte-copy from store path to external target path. Refuses to overwrite unmanaged targets; writes a sentinel in `.textus/sentinels/` to track managed targets.
69
-
70
- ### 3. Extension surface
71
-
72
- Declared in the manifest, loaded on demand, dispatched by `Store` and `Refresh`.
73
-
74
- - **`Hooks::Registry`** — loads one `.rb` per hook from `.textus/hooks/`, registers callables under their `(event, name)`. Single source of truth via the `EVENTS` table (rpc vs pubsub, arg shape, failure semantics). For pub-sub events it also forwards registrations to the `Hooks::Dispatcher`.
75
- - **`Hooks::Dispatcher`** — first-class pub/sub for lifecycle events (`:put`, `:delete`, `:refresh`, `:build`, `:accept`, `:publish`, `:mv`, `:reject`, `:loaded`). Owns the 2-second per-handler timeout and the audit-on-failure middleware (raising handlers do not abort the write; they produce an `event_error` audit row). Embedded callers can `store.dispatcher.subscribe(:put, :name) { ... }` outside `.textus/hooks/`.
76
- - **`Hooks::Builtin`** — ships built-in `:fetch` hooks (e.g. json, csv, ical-events, rss) available without user-supplied hooks.
77
- - **`Refresh`** — `refresh` verb: looks up the `:fetch` hook for a key, invokes it, normalizes the result by declared format, writes through `Store::Writer` with an etag check.
78
-
79
- ### 4. Operational tooling
80
-
81
- First-class CLI verbs that don't fit the read/write/build axes. Read-mostly; side modules off CLI.
82
-
83
- - **`Doctor`** — `doctor` verb: orchestrator that runs 9 builtin checks under `Doctor::Check::*`. Talks to Manifest/Schema/Entry/Hooks::Registry directly.
84
- - **`Doctor::Check`** — explicit base class for doctor checks. Each of the 9 builtin checks is its own file under `lib/textus/doctor/check/`.
85
- - **`MigrateKeys`** — `key migrate` and `key mv` verbs; computes renames against the manifest.
86
- - **`Schema::Tools`** — `schema init`, `schema diff`, `schema migrate` verbs.
87
- - **`Init`** — `init` verb: scaffolds `.textus/` with the five zone directories, baseline schemas, empty audit log, starter manifest.
88
- - **`Intro`** — `intro` verb: emits the human/agent-facing onboarding payload.
89
- - **`Store::View`** — read-only projection over `Store::Reader` for hook code that should not mutate.
90
- - **`Key::Distance`** — Levenshtein-ish suggestion for `did-you-mean` on unknown keys.
91
-
92
- ### 5. Primitives
93
-
94
- - **`Errors`** — `Textus::Error` subclasses, each carrying a stable `code`, a `details` hash, and an `exit_code`. `CLI` catches them at the top level and emits the §8 error envelope on stdout. In `--format=json` mode, errors are **never** written to stderr — agents read stdout.
95
- - **`version`** — gem semver string (independent of the wire protocol `textus/2`).
96
-
97
- ## Invariants
98
-
99
- - **CLI is the only entry point.** No public API surface guarantees outside the verbs CLI exposes.
100
- - **Manifest is pure.** Reads at load, no mutation.
101
- - **Store::Writer is the only module that writes to working-store entry files.** Reader reads them; Mover moves them within a zone. Init, MigrateKeys, Publisher, Builder, AuditLog write to **other** parts of `.textus/` (scaffolding, sentinels, audit log, derived targets) — they do not edit existing entry files behind the Store facade's back.
102
- - **`name:` frontmatter matches file basename.** Enforced on read and write.
103
- - **Zone semantics live in the manifest, not in directory names.** A project may rename `state/` to anything; the manifest declares which zone each entry belongs to.
104
- - **`freshness` does not execute anything.** It walks every entry, matches it against the top-level `policies:` block, and returns each entry's verdict (`fresh|stale|never_refreshed|no_policy`). Build runners execute. This is the §5.1 "dataflow oracle, not executor" boundary.
105
-
106
- ## Policy resolution
107
-
108
- Top-level `policies:` are parsed into a `Manifest::Policies` collection. Resolution is by key, slot-aware, most-specific-wins:
109
-
110
- ```
111
- Manifest#policies_for(key)
112
- └─► Manifest::Policies#for(key)
113
- ├─► Policy::Matcher (specificity ranking)
114
- └─► returns PolicySet { refresh, handler_allowlist, promote, retention }
115
-
116
-
117
- consumers: Refresh::Worker
118
- Doctor checks
119
- Reads::PolicyExplain
120
- ```
121
-
122
- Two blocks at the same specificity filling the same slot for the same key is a manifest error reported by `doctor` (`policy_ambiguity`). Custom-named zones see no special handling — policies match against the full key.
123
-
124
- ## What this implementation deliberately leaves out
125
-
126
- - **No process spawning.** Even `stale` does not execute. Build runners do that.
127
- - **No transport.** No HTTP server, no socket, no MCP server in this gem. Those are downstream wrappers (see [`./conventions.md`](./conventions.md)).
128
- - **No indexes.** Listing walks the filesystem each time. Premature optimisation for v1.
129
- - **No locking.** Etag is advisory; concurrent writers can still race. Left to v1.x (§14 open question).