textus 0.4.0 → 0.8.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -2
  3. data/README.md +38 -28
  4. data/SPEC.md +84 -147
  5. data/docs/architecture.md +82 -28
  6. data/lib/textus/builder/pipeline.rb +56 -0
  7. data/lib/textus/builder/renderer/json.rb +42 -0
  8. data/lib/textus/builder/renderer/markdown.rb +22 -0
  9. data/lib/textus/builder/renderer/text.rb +14 -0
  10. data/lib/textus/builder/renderer/yaml.rb +42 -0
  11. data/lib/textus/builder/renderer.rb +17 -0
  12. data/lib/textus/builder.rb +9 -114
  13. data/lib/textus/cli/group/hook.rb +11 -0
  14. data/lib/textus/cli/group/key.rb +12 -0
  15. data/lib/textus/cli/group/schema.rb +13 -0
  16. data/lib/textus/cli/group.rb +51 -0
  17. data/lib/textus/cli/verb/accept.rb +15 -0
  18. data/lib/textus/cli/verb/build.rb +13 -0
  19. data/lib/textus/cli/verb/delete.rb +16 -0
  20. data/lib/textus/cli/verb/deps.rb +12 -0
  21. data/lib/textus/cli/verb/doctor.rb +15 -0
  22. data/lib/textus/cli/verb/get.rb +12 -0
  23. data/lib/textus/cli/verb/hook_run.rb +48 -0
  24. data/lib/textus/cli/verb/hooks.rb +50 -0
  25. data/lib/textus/cli/verb/init.rb +14 -0
  26. data/lib/textus/cli/verb/intro.rb +11 -0
  27. data/lib/textus/cli/verb/list.rb +14 -0
  28. data/lib/textus/cli/verb/migrate_keys.rb +16 -0
  29. data/lib/textus/cli/verb/mv.rb +17 -0
  30. data/lib/textus/cli/verb/published.rb +11 -0
  31. data/lib/textus/cli/verb/put.rb +50 -0
  32. data/lib/textus/cli/verb/rdeps.rb +12 -0
  33. data/lib/textus/cli/verb/refresh.rb +15 -0
  34. data/lib/textus/cli/verb/schema.rb +12 -0
  35. data/lib/textus/cli/verb/schema_diff.rb +12 -0
  36. data/lib/textus/cli/verb/schema_init.rb +16 -0
  37. data/lib/textus/cli/verb/schema_migrate.rb +16 -0
  38. data/lib/textus/cli/verb/stale.rb +14 -0
  39. data/lib/textus/cli/verb/uid.rb +12 -0
  40. data/lib/textus/cli/verb/where.rb +12 -0
  41. data/lib/textus/cli/verb.rb +62 -0
  42. data/lib/textus/cli.rb +44 -385
  43. data/lib/textus/doctor/check/audit_log.rb +50 -0
  44. data/lib/textus/doctor/check/hooks.rb +29 -0
  45. data/lib/textus/doctor/check/illegal_keys.rb +49 -0
  46. data/lib/textus/doctor/check/manifest_files.rb +38 -0
  47. data/lib/textus/doctor/check/schema_violations.rb +22 -0
  48. data/lib/textus/doctor/check/schemas.rb +26 -0
  49. data/lib/textus/doctor/check/sentinels.rb +57 -0
  50. data/lib/textus/doctor/check/templates.rb +26 -0
  51. data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
  52. data/lib/textus/doctor/check.rb +30 -0
  53. data/lib/textus/doctor.rb +29 -264
  54. data/lib/textus/entry/base.rb +30 -0
  55. data/lib/textus/entry/json.rb +11 -5
  56. data/lib/textus/entry/markdown.rb +5 -5
  57. data/lib/textus/entry/text.rb +4 -4
  58. data/lib/textus/entry/yaml.rb +11 -5
  59. data/lib/textus/entry.rb +2 -7
  60. data/lib/textus/envelope.rb +30 -0
  61. data/lib/textus/errors.rb +2 -2
  62. data/lib/textus/hooks/builtin.rb +70 -0
  63. data/lib/textus/hooks/dispatcher.rb +49 -0
  64. data/lib/textus/hooks/loader.rb +26 -0
  65. data/lib/textus/hooks/registry.rb +73 -0
  66. data/lib/textus/init.rb +14 -11
  67. data/lib/textus/intro.rb +16 -18
  68. data/lib/textus/key/distance.rb +55 -0
  69. data/lib/textus/key/grammar.rb +33 -0
  70. data/lib/textus/key/path.rb +17 -0
  71. data/lib/textus/manifest/entry.rb +199 -0
  72. data/lib/textus/manifest.rb +20 -254
  73. data/lib/textus/migrate_keys.rb +1 -1
  74. data/lib/textus/projection.rb +6 -5
  75. data/lib/textus/proposal.rb +4 -4
  76. data/lib/textus/refresh.rb +17 -17
  77. data/lib/textus/schema/tools.rb +89 -0
  78. data/lib/textus/store/audit_log.rb +71 -0
  79. data/lib/textus/store/mover.rb +121 -0
  80. data/lib/textus/store/reader.rb +67 -0
  81. data/lib/textus/store/staleness.rb +133 -0
  82. data/lib/textus/store/validator.rb +56 -0
  83. data/lib/textus/store/view.rb +29 -0
  84. data/lib/textus/store/writer.rb +132 -0
  85. data/lib/textus/store.rb +26 -527
  86. data/lib/textus/version.rb +2 -2
  87. data/lib/textus.rb +14 -29
  88. metadata +78 -8
  89. data/lib/textus/audit_log.rb +0 -32
  90. data/lib/textus/builtin_actions.rb +0 -68
  91. data/lib/textus/extension_registry.rb +0 -61
  92. data/lib/textus/extensions.rb +0 -33
  93. data/lib/textus/key_distance.rb +0 -53
  94. data/lib/textus/schema_tools.rb +0 -87
  95. data/lib/textus/store_view.rb +0 -27
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98c2ce5525bbf9c05ebdb5eeaacde8e208253ee878d7d0722ff192435168f69f
4
- data.tar.gz: c16cd5657396884c646331912d988838e0f8ed2002fc76d47cc54383dc434f19
3
+ metadata.gz: 91be0acd415a4b41d96e896015dfd19e58fc7867d007332456499ec5ceb8a6aa
4
+ data.tar.gz: 576d361ebba900b33b2f612b1a0a51d0373cb4a5e1fbd503e43c2bb248ca9306
5
5
  SHA512:
6
- metadata.gz: 7478459f9672474c32f37b59a7134309cf12f6a7455a808c24b6ac717820ff8c8aad5a5187a6dbe5e0c73f98c04296ce95eff2cac8ea75144f559df8cea3fe80
7
- data.tar.gz: '08a29e9090f6558a010009e3562a7ec8d6ddf73454bb09b10b5aeaf3def24d9d11905078d62807b8e1f0936b00889a66df8c55d68b7dfc411addd90e60ae1a93'
6
+ metadata.gz: c551c60809be1eefc0d964092adc62a8555cb6f0df3585e775fd2fda1549f1e7d811fb2bfeec992dd3ce872dccb16fd9300a66314d5e51e8f24a98b57f29da5b
7
+ data.tar.gz: c96ec13692d6366a916b125266afb70aabcca439594db4e8fd1b434f1db1a88e216938cbf64bc69e64bcfef241ca7314664398c656f1d7333a7480a7d02852f9
data/CHANGELOG.md CHANGED
@@ -5,10 +5,155 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  The **gem version** (`0.x.y`) is distinct from the **protocol version**
8
- (currently `textus/1`, embedded in every envelope as `protocol`). The protocol
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
- ## [Unreleased]
11
+ ## 0.8.0 — Folder restructure & Zeitwerk autoload (2026-05-21)
12
+
13
+ ### Breaking — internal Ruby renames
14
+ Internal Ruby constants renamed. No deprecation aliases; downstream code referencing internals must update directly.
15
+ - `Textus::EventBus` → `Textus::Hooks::Dispatcher`
16
+ - `Textus::HookRegistry` → `Textus::Hooks::Registry`
17
+ - `Textus::BuiltinHooks` → `Textus::Hooks::Builtin`
18
+ - `Textus::Extensions` (module) → `Textus::Hooks::Loader`
19
+ - `Textus::StoreView` → `Textus::Store::View`
20
+ - `Textus::AuditLog` → `Textus::Store::AuditLog`
21
+ - `Textus::ManifestEntry` → `Textus::Manifest::Entry`
22
+ - `Textus::KeyDistance` → `Textus::Key::Distance`
23
+ - `Textus::Path` → `Textus::Key::Path`
24
+ - `Textus::SchemaTools` → `Textus::Schema::Tools`
25
+ - `Textus::CLI::<Verb>` → `Textus::CLI::Verb::<Verb>` (all 23 verbs)
26
+ - `Textus::CLI::<Name>Group` → `Textus::CLI::Group::<Name>` (key, schema, hook)
27
+ - `Textus::Doctor::Check::Extensions` → `Textus::Doctor::Check::Hooks`
28
+ - `Hooks::Registry#initialize` keyword `bus:` renamed to `dispatcher:`.
29
+
30
+ ### Breaking — doctor CLI surface
31
+ - `textus doctor --check=extensions` → `textus doctor --check=hooks`. The check name listed in `ALL_CHECKS` and the SPEC §10.2 enumeration changes from `"extensions"` to `"hooks"`, matching the hook subsystem rename in 0.6.
32
+ - Doctor issue `code` for broken hook files: `extension.load_failed` → `hook.load_failed`.
33
+ - Doctor::Check::Hooks now inspects `.textus/hooks/` (matches `Store#load_extensions`). Previously inspected `.textus/extensions/`, which was the pre-0.6 directory — the check was dead code on any store created with current `textus init`.
34
+
35
+ ### Added
36
+ - `Textus::Entry::Base` — explicit strategy interface for entry formats. Concrete strategies inherit and override.
37
+ - `Textus::Builder::Renderer` — explicit base for output renderers.
38
+ - `Textus::Doctor::Check` — explicit base for doctor checks. Each builtin check (9 total) is now its own file under `lib/textus/doctor/check/`.
39
+
40
+ ### Changed
41
+ - Per-format schema validation moved from `Store::Reader`/`Store::Writer` onto `Entry::Base#validate_against`. Reader/Writer no longer carry a `case mentry.format` switch.
42
+ - `Textus::Doctor` reduced to an orchestrator; the 9 builtin checks live under `Doctor::Check::*`.
43
+ - `lib/textus.rb` switched to Zeitwerk autoload. The manual `require_relative` tree (75 lines) is gone.
44
+ - `lib/textus/builder/renderers/` directory renamed to `renderer/` (singular) to match `Builder::Renderer::*` namespace.
45
+
46
+ ### Migration
47
+ External code referencing the old internal constants must rename. `Textus.hook`, `Textus.with_registry`, the entire CLI surface, and the `textus/2` wire format are unchanged. The published API (`Store`, `Manifest`, `Envelope`, `Etag`, `Role`, `Error` hierarchy, `Builder`, `Doctor`, `Refresh`, `Init`, `CLI.run`) is unchanged.
48
+
49
+ ## 0.7.0 — Reader/Writer split, EventBus, Builder pipeline (2026-05-21)
50
+
51
+ ### Added
52
+ - `Textus::EventBus` is now the publish/subscribe core for lifecycle events. Embedded callers can `store.bus.subscribe(:put, :name) { ... }` outside the `.textus/hooks/` directory. Hook semantics, audit behavior, and the 2-second timeout are unchanged.
53
+
54
+ ### Changed
55
+ - Internal: extracted `Textus::Path` and `Textus::Envelope` value modules; `Manifest`, `Store`, `Staleness`, and `Builder` now share the same path/envelope construction.
56
+ - Internal: split `Textus::Store` into `Store::Reader` and `Store::Writer`. Public API unchanged. `Mover`, `Validator`, and `Staleness` now take explicit collaborators instead of the full store.
57
+ - Internal: removed `Store::Events`; replaced by the bus.
58
+ - Internal: restructured `Textus::Builder` as a step pipeline (`LoadSources → Project → Render → Write`) with one renderer per format (`markdown/text/json/yaml`). Adding a new output format is now a single-file change.
59
+
60
+ ## 0.6.1 — Deprecation cleanup
61
+
62
+ ### Breaking
63
+ - Flat verb aliases promised "removed in 0.6" are now actually removed:
64
+ - `textus mv` → `textus key mv`
65
+ - `textus uid` → `textus key uid`
66
+ - `textus migrate-keys` → `textus key migrate`
67
+ - `textus schema-init` → `textus schema init`
68
+ - `textus schema-diff` → `textus schema diff`
69
+ - `textus schema-migrate` → `textus schema migrate`
70
+ - `textus schema KEY` (positional) → `textus schema show KEY`
71
+ - `textus action NAME` → `textus hook run NAME`
72
+ - `Textus::CLI::Action` class renamed to `Textus::CLI::HookRun`; file `cli/action.rb` → `cli/hook_run.rb`.
73
+ - `Textus::CLI::DeprecatedAliasMixin` module deleted (no remaining users).
74
+ - `textus migrate v2` command removed along with `Textus::MigrateV2` module and `Textus::CLI::Migrate` class. The migration was a one-line manifest rewrite (`version: textus/1` → `version: textus/2`); on-disk entry shapes never changed. To upgrade a `textus/1` manifest, edit `.textus/manifest.yaml` directly. `Manifest.load` still detects the old version and prints the exact edit in its error message.
75
+
76
+ ## 0.6.0 — Hook unification
77
+
78
+ ### Breaking
79
+ - Four DSL verbs (`Textus.action`, `Textus.reducer`, `Textus.hook`, `Textus.doctor_check`) collapsed into one: `Textus.hook(event, name, **opts) { ... }`.
80
+ - `ExtensionRegistry` class renamed to `HookRegistry`.
81
+ - `.textus/extensions/` directory renamed to `.textus/hooks/`. No back-compat read.
82
+ - Manifest: `source.action:` → `source.fetch:` (also renames the registry event from `:action` to `:fetch` and the `ManifestEntry#action`/`#action_config` accessors to `#fetch`/`#fetch_config`).
83
+ - Manifest: `projection.reducer:` → `projection.reduce:`.
84
+ - CLI: `textus extension list` → `textus hook list`; output rows keyed by `event`+`mode` instead of `kind`.
85
+ - CLI: `textus put --action=NAME` → `textus put --fetch=NAME`.
86
+ - Pub-sub hooks gain an optional `keys:` glob filter for per-key scoping.
87
+ - Hook signatures standardized: `store:` is now mandatory and first on every hook (`:reduce` previously had no `store:`). Event-specific kwargs follow.
88
+ - `:accept` event renames `pending_key:` → `key:` to match every other lifecycle event.
89
+ - All event names are now verbs in a uniform grammar (RPC verbs `:fetch :reduce :check`; pub-sub verbs `:put :delete :refresh :build :accept`).
90
+
91
+ ### New
92
+ - `EVENTS` metadata table on `HookRegistry` is the single source of truth for event names, argument shapes, return shapes, and failure semantics (rpc vs pubsub).
93
+ - Shape-check at registration: callable kwargs are verified against the EVENTS table at load time; mismatched signatures raise `UsageError` immediately instead of surfacing at fire time.
94
+
95
+ ## 0.5.0 — Wire protocol `textus/2`; CLI restructure; Store split (breaking)
96
+
97
+ This release reshapes the public surface ahead of 1.0. The wire protocol bumps to `textus/2`; the CLI grows nested subcommand groups; `Store` is decomposed into a thin facade plus four focused helpers; the audit log finally matches its documented NDJSON shape; and a pile of pre-0.4 cruft gets cut.
98
+
99
+ ### Wire protocol — `textus/1` → `textus/2`
100
+
101
+ - **Breaking:** every envelope now carries `"_meta"` instead of `"frontmatter"`. For json/yaml entries, envelope `content` no longer carries a duplicate `_meta` — the metadata lives only at the envelope's top level.
102
+ - **Breaking:** `Manifest.load` refuses `textus/1` manifests with a pointer at the new migration command. On-disk file shapes are unchanged — only the manifest version string changes.
103
+ - **New:** `textus migrate v2` flips `version: textus/1` to `version: textus/2` in `.textus/manifest.yaml`. One command, no file edits.
104
+ - **Internal cleanup:** `Store#extract_uid`, `enforce_name_match!`, `serialize_for_put`, `validate_all`, and `build_envelope` no longer format-switch — metadata access is uniform. Role-authority validation now works for json/yaml entries (was markdown-only).
105
+ - **API:** `Store#put` keyword renamed from `frontmatter:` to `meta:`. Action callbacks return `_meta:` (formerly `frontmatter:`).
106
+
107
+ ### CLI — nested subcommand groups
108
+
109
+ - **New:** `textus key {mv, uid, migrate}`, `textus schema {show, init, diff, migrate}`, `textus extension {list, run}`. Discoverable, groupable, scales.
110
+ - **Deprecated (removed in 0.6):** the flat verbs `mv`, `uid`, `migrate-keys`, `schema-init`, `schema-diff`, `schema-migrate`, `extensions`, `action` still work but emit a stderr deprecation warning. `textus schema KEY` (positional dotted-key form) keeps working via a back-compat fallback in `SchemaGroup`.
111
+ - **New:** `textus list`, `textus get`, etc. default to JSON output. `--format=json` is still accepted; non-json values still raise.
112
+ - **CLI refactor:** `lib/textus/cli.rb` shrank from 434 LOC to ~100 LOC. Every verb is now a small command-object file under `lib/textus/cli/`. Dispatch is a frozen `VERBS` hash.
113
+
114
+ ### Audit log — true NDJSON
115
+
116
+ - **Breaking:** `.textus/audit.log` rows are now one JSON object per line (`{"ts":..., "role":..., "verb":..., "key":..., "etag_before":..., "etag_after":...}`). Missing etags are `null`, not the string `"NULL"`.
117
+ - **Structural shape:** `from_key`, `to_key`, `uid` (mv rows) live at the top level; arbitrary contextual data goes into an `extras` sub-object that is omitted when empty.
118
+ - **Back-compat:** legacy TSV rows still parse during 0.5 — `AuditLog#last_writer_for` and `Doctor#check_audit_log` accept both formats. Legacy support removed in 0.6.
119
+
120
+ ### Doctor
121
+
122
+ - **New:** `textus doctor --check=schema_violations[,name,…]` runs only the named built-in checks. The 9 built-ins are `manifest_files`, `schemas`, `templates`, `extensions`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`. Extension checks always run.
123
+ - **Breaking:** the standalone `textus validate-all` verb is gone. Use `textus doctor --check=schema_violations` instead. The internal `Store#validate_all` Ruby method is unchanged.
124
+
125
+ ### Manifest / store cleanup
126
+
127
+ - **Breaking:** `LEGACY_ZONES` fallback removed. Manifests must declare a `zones:` block explicitly (init scaffold does this).
128
+ - **Breaking:** legacy syntax errors removed for `source.parse` / `source.from` / `source.fetcher` / top-level `hooks:`. Those names were rejected with helpful errors in 0.4; in 0.5 they get the generic "unknown key" error from YAML parsing.
129
+ - **Internal:** `ManifestEntry` moved to its own file (`lib/textus/manifest_entry.rb`).
130
+
131
+ ### Store split
132
+
133
+ - **Internal:** `lib/textus/store.rb` shrank from 617 LOC to ~312 LOC. Four focused helpers live under `lib/textus/store/`:
134
+ - `events.rb` (31 LOC) — `fire_event` hook plumbing
135
+ - `validator.rb` (53 LOC) — `validate_all` body
136
+ - `staleness.rb` (142 LOC) — `stale` body (was 5 rubocop disables)
137
+ - `mover.rb` (118 LOC) — `mv` body
138
+ - No public-API change. `Store` facade delegates to each helper one-line.
139
+
140
+ ### Migration cheat-sheet
141
+
142
+ ```sh
143
+ # 1. Upgrade the gem
144
+ gem update textus # ≥ 0.5.0
145
+
146
+ # 2. Upgrade the store
147
+ cd /path/to/your/store
148
+ textus migrate v2 # flips manifest version
149
+
150
+ # 3. Anything else?
151
+ # - Audit log: existing TSV rows still readable; new rows are NDJSON.
152
+ # - CLI scripts: replace `textus mv ...` with `textus key mv ...`
153
+ # (and 7 similar aliases). Old forms work through 0.5 with a stderr warning.
154
+ # - Ruby callers of Store#put: pass `meta:` instead of `frontmatter:`.
155
+ # - Anything reading envelope["frontmatter"]: read envelope["_meta"] instead.
156
+ ```
12
157
 
13
158
  ## 0.4.0 — Extension API redesign (breaking)
14
159
 
data/README.md CHANGED
@@ -7,14 +7,14 @@
7
7
 
8
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.
9
9
 
10
- Reference implementation in Ruby. Wire format `textus/1`. SPEC: [`SPEC.md`](SPEC.md). Implementation notes: [`docs/`](docs/).
10
+ Reference implementation in Ruby. Wire format `textus/2`. SPEC: [`SPEC.md`](SPEC.md). Implementation notes: [`docs/`](docs/).
11
11
 
12
12
  ## Versioning
13
13
 
14
14
  Two versions, deliberately independent:
15
15
 
16
- - **Protocol wire string:** `textus/1`. Stable; breaking changes require `textus/2`.
17
- - **Gem version:** semver, currently `0.2.0`. Gem `0.x.y` and `1.x` both speak `textus/1`.
16
+ - **Protocol wire string:** `textus/2`. Stable; breaking changes require `textus/3`.
17
+ - **Gem version:** semver, currently `0.8.0`. The gem version is decoupled from the protocol string — internal refactors bump the gem; only wire-format changes bump the protocol.
18
18
 
19
19
  Envelope payloads carry the `protocol` field. The gem version is irrelevant to the wire format.
20
20
 
@@ -60,23 +60,25 @@ Manifest `path:` fields are relative to `.textus/zones/`. So `working.network.or
60
60
  Read and write:
61
61
 
62
62
  ```sh
63
- textus get working.network.org.jane --format=json
64
- textus list --zone=working --format=json
65
- echo '{"frontmatter":{"name":"bob","org":"acme"},"body":"hi\n"}' \
66
- | textus put working.network.org.bob --as=human --stdin --format=json
67
- textus stale --zone=derived --format=json
63
+ textus get working.network.org.jane
64
+ textus list --zone=working
65
+ echo '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
66
+ | textus put working.network.org.bob --as=human --stdin
67
+ textus stale --zone=derived
68
68
  ```
69
69
 
70
+ (All verbs return JSON envelopes by default; pass `--format=json` explicitly if you prefer.)
71
+
70
72
  For the full shape — Claude plugin with agents, skills, commands, pending walkthrough, intake action — see [`examples/claude-plugin/`](examples/claude-plugin/).
71
73
 
72
- ## What 0.2 ships
74
+ ## What ships today
73
75
 
74
76
  - **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`).
75
77
  - **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.
76
- - **Stable identity (`uid:`).** 16-char hex, auto-minted on first `put`, preserved across writes and moves. `textus 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.
77
- - **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus migrate-keys --dry-run|--write` rewrites existing stores with illegal segments deterministically.
78
+ - **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.
79
+ - **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.
78
80
  - **`textus intro`.** One-shot store orientation: zones with writers + purposes, entry families with schemas and publish targets, loaded extensions, write flows per role, the full CLI verb table. The boot signal for any agent — one tool call and it knows your store.
79
- - **`textus doctor`.** Health check across 8 categories: missing schemas/templates, broken extensions, illegal nested keys, sentinel drift, audit log readability. Returns `ok: true` only when nothing is wrong; warnings and info don't flip the bit.
81
+ - **`textus doctor`.** Health check across 9 categories: missing schemas/templates, broken extensions, 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.
80
82
  - **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.
81
83
 
82
84
  Symlink-mode publish was removed; publish is `FileUtils.cp` + sentinel. Sentinels for published files live under `.textus/sentinels/<target_rel>.textus-managed.json` so consumer directories stay clean. Legacy sibling sentinels auto-migrate on next publish.
@@ -93,22 +95,22 @@ All verbs accept `--format=json` and return the envelope defined in SPEC §8. Wr
93
95
  | `list [--prefix=K] [--zone=Z]` | Enumerate keys |
94
96
  | `where K` | Resolve a key to its filesystem path |
95
97
  | `get K` | Full envelope (frontmatter, body, uid, etag, format) |
96
- | `schema K` | Schema bound to an entry |
98
+ | `schema show K` | Schema bound to an entry |
97
99
  | `stale [--prefix=K] [--zone=Z]` | List stale derived/intake entries |
98
100
  | `deps K` / `rdeps K` | Forward / reverse projection dependencies |
99
101
  | `published` | List `publish_to:` targets and their backing keys |
100
- | `validate-all` | Validate every entry against its schema |
101
- | `extensions list [--kind=K]` | Registered actions, reducers, hooks, doctor_checks |
102
+ | `doctor --check=schema_violations` | Validate every entry against its schema |
103
+ | `hook list [--event=E]` | Registered hooks grouped by event (fetch, reduce, check, put, delete, refresh, build, accept) |
102
104
 
103
105
  **Write:**
104
106
 
105
107
  | Verb | Role |
106
108
  |---|---|
107
109
  | `put K --stdin --as=R [--action=NAME]` | per zone |
108
- | `action NAME [--key=val] [--as=R]` | per zone written (invoke a registered action) |
110
+ | `hook run NAME [--key=val] [--as=R]` | per zone written (invoke a registered fetch hook) |
109
111
  | `delete K --if-etag=E --as=R` | per zone |
110
112
  | `refresh K --as=script` | per zone (typically `script`) |
111
- | `mv old new --as=R [--dry-run]` | per zone (same-zone moves; uid preserved) |
113
+ | `key mv old new --as=R [--dry-run]` | per zone (same-zone moves; uid preserved) |
112
114
  | `build [--prefix=K] [--dry-run]` | `build` |
113
115
  | `accept K --as=human` | `human` only |
114
116
 
@@ -117,16 +119,16 @@ All verbs accept `--format=json` and return the envelope defined in SPEC §8. Wr
117
119
  | Verb | Purpose |
118
120
  |---|---|
119
121
  | `doctor` | 8 health checks; `ok: true` when clean |
120
- | `migrate-keys [--dry-run]` | Rename files whose basenames violate the strict key grammar |
122
+ | `key migrate [--dry-run]` | Rename files whose basenames violate the strict key grammar |
121
123
 
122
124
  **Scaffolding (human-only):**
123
125
 
124
126
  | Verb | Purpose |
125
127
  |---|---|
126
128
  | `init` | Scaffold a fresh `.textus/` |
127
- | `schema-init NAME` | Stub a schema |
128
- | `schema-diff NAME` | Compare a schema against entries that claim it |
129
- | `schema-migrate NAME [--rename=OLD:NEW]` | Rewrite frontmatter keys across affected entries |
129
+ | `schema init NAME` | Stub a schema |
130
+ | `schema diff NAME` | Compare a schema against entries that claim it |
131
+ | `schema migrate NAME [--rename=OLD:NEW]` | Rewrite frontmatter keys across affected entries |
130
132
 
131
133
  ## Zones and roles
132
134
 
@@ -146,16 +148,24 @@ Derived entries declare a `projection:` (`select`, `pluck`, `sort_by`, `limit`,
146
148
 
147
149
  `publish_to: [path]` byte-copies a single derived file to one target. `publish_each: "template/{basename}.md"` on a nested entry byte-copies every leaf to its templated target — substitutes `{leaf}`, `{basename}`, `{key}`, `{ext}`. Sentinels for every published file live under `.textus/sentinels/`. See SPEC §5.2, §5.3, §5.12.
148
150
 
149
- ## Extensions
151
+ ## Extension points
152
+
153
+ textus exposes one DSL verb:
154
+
155
+ ```ruby
156
+ Textus.hook(event, name, **opts) { |args| ... }
157
+ ```
158
+
159
+ Drop `.rb` files into `.textus/hooks/`. Events:
150
160
 
151
- Four DSL verbs, registered in `.textus/extensions/*.rb`. Each `Store` gets its own registry — no global state.
161
+ - `:fetch` bring bytes in from elsewhere (returns `{frontmatter:, body:}`)
162
+ - `:reduce` — transform rows during projection (returns rows)
163
+ - `:check` — custom doctor check (returns issues)
164
+ - `:put`, `:delete`, `:refresh`, `:build`, `:accept` — react to lifecycle events
152
165
 
153
- - **`Textus.action(:name) do |config:, store:, args:|`** — runs in three invocation modes (intake refresh, `textus action` verb, `put --action`). Returns `{frontmatter:, body:}`, `{content:}`, or `{body:}` when its return is consumed (intake and put-fetch); writes via `store.put` for side-effectful work (verb mode). The store normalizes all three return shapes. Configured via `source.action` in the manifest for intake. Five built-ins ship: `json`, `csv`, `markdown-links`, `ical-events`, `rss`.
154
- - **`Textus.reducer(:name) do |rows:, config:|`** — shapes rows in a derived projection. Pure function. Configured via `projection.reducer`. May return an Array (templated builds) or a Hash (templateless json/yaml).
155
- - **`Textus.hook(:event, :name) do |kwargs|`** — fires on `:put`, `:delete`, `:refresh`, `:build`, or `:accept`. In-process; 2 s timeout per hook; failures land in the audit log as `event_error` rows.
156
- - **`Textus.doctor_check(:name) do |store:|`** — contributes whole-tree validators to `textus doctor`. Returns an array of issue hashes `{code, level, subject, message, fix}` that merge into the doctor report. Timeouts and exceptions surface as `doctor_check.*` issues; they do not abort the doctor run.
166
+ See SPEC.md §5.10 for the full contract.
157
167
 
158
- 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 and §5.11.
168
+ 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.
159
169
 
160
170
  ## Examples
161
171