textus 0.8.0 → 0.9.2

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +245 -0
  3. data/README.md +54 -26
  4. data/SPEC.md +194 -63
  5. data/docs/architecture.md +22 -4
  6. data/docs/conventions.md +24 -17
  7. data/lib/textus/application/context.rb +68 -0
  8. data/lib/textus/application/reads/audit.rb +69 -0
  9. data/lib/textus/application/reads/blame.rb +79 -0
  10. data/lib/textus/application/reads/freshness.rb +77 -0
  11. data/lib/textus/application/reads/get.rb +62 -0
  12. data/lib/textus/application/reads/policy_explain.rb +39 -0
  13. data/lib/textus/application/refresh/all.rb +41 -0
  14. data/lib/textus/application/refresh/orchestrator.rb +68 -0
  15. data/lib/textus/application/refresh/worker.rb +79 -0
  16. data/lib/textus/application/writes/accept.rb +43 -0
  17. data/lib/textus/application/writes/build.rb +24 -0
  18. data/lib/textus/application/writes/delete.rb +37 -0
  19. data/lib/textus/application/writes/publish.rb +25 -0
  20. data/lib/textus/application/writes/put.rb +44 -0
  21. data/lib/textus/builder.rb +27 -14
  22. data/lib/textus/cli/group/policy.rb +11 -0
  23. data/lib/textus/cli/verb/accept.rb +2 -1
  24. data/lib/textus/cli/verb/audit.rb +31 -0
  25. data/lib/textus/cli/verb/blame.rb +17 -0
  26. data/lib/textus/cli/verb/build.rb +2 -1
  27. data/lib/textus/cli/verb/delete.rb +2 -1
  28. data/lib/textus/cli/verb/freshness.rb +17 -0
  29. data/lib/textus/cli/verb/get.rb +8 -1
  30. data/lib/textus/cli/verb/hook_run.rb +3 -3
  31. data/lib/textus/cli/verb/policy_explain.rb +15 -0
  32. data/lib/textus/cli/verb/policy_list.rb +25 -0
  33. data/lib/textus/cli/verb/put.rb +5 -4
  34. data/lib/textus/cli/verb/refresh.rb +2 -1
  35. data/lib/textus/cli/verb/refresh_stale.rb +19 -0
  36. data/lib/textus/cli/verb/reject.rb +15 -0
  37. data/lib/textus/cli.rb +16 -2
  38. data/lib/textus/composition.rb +71 -0
  39. data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
  40. data/lib/textus/doctor/check/intake_registration.rb +46 -0
  41. data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
  42. data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
  43. data/lib/textus/doctor.rb +5 -1
  44. data/lib/textus/domain/action.rb +9 -0
  45. data/lib/textus/domain/freshness/evaluator.rb +30 -0
  46. data/lib/textus/domain/freshness/policy.rb +18 -0
  47. data/lib/textus/domain/freshness/verdict.rb +12 -0
  48. data/lib/textus/domain/outcome.rb +10 -0
  49. data/lib/textus/domain/permission.rb +15 -0
  50. data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
  51. data/lib/textus/domain/policy/matcher.rb +51 -0
  52. data/lib/textus/domain/policy/promote.rb +24 -0
  53. data/lib/textus/domain/policy/refresh.rb +48 -0
  54. data/lib/textus/domain/policy.rb +7 -0
  55. data/lib/textus/hooks/builtin.rb +5 -5
  56. data/lib/textus/hooks/dispatcher.rb +15 -1
  57. data/lib/textus/hooks/dsl.rb +18 -0
  58. data/lib/textus/hooks/registry.rb +12 -5
  59. data/lib/textus/infra/clock.rb +9 -0
  60. data/lib/textus/infra/event_bus.rb +27 -0
  61. data/lib/textus/infra/publisher.rb +73 -0
  62. data/lib/textus/infra/refresh/detached.rb +38 -0
  63. data/lib/textus/infra/refresh/lock.rb +44 -0
  64. data/lib/textus/init.rb +71 -28
  65. data/lib/textus/intro.rb +22 -14
  66. data/lib/textus/manifest/entry.rb +18 -9
  67. data/lib/textus/manifest/policies.rb +83 -0
  68. data/lib/textus/manifest.rb +30 -0
  69. data/lib/textus/proposal.rb +4 -21
  70. data/lib/textus/publisher.rb +4 -69
  71. data/lib/textus/refresh.rb +9 -44
  72. data/lib/textus/store/mover.rb +14 -9
  73. data/lib/textus/store/reader.rb +10 -8
  74. data/lib/textus/store/staleness.rb +4 -16
  75. data/lib/textus/store/validator.rb +46 -20
  76. data/lib/textus/store/view.rb +8 -19
  77. data/lib/textus/store/writer.rb +51 -14
  78. data/lib/textus/store.rb +32 -12
  79. data/lib/textus/version.rb +1 -1
  80. data/lib/textus.rb +1 -0
  81. metadata +46 -2
  82. data/lib/textus/cli/verb/stale.rb +0 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91be0acd415a4b41d96e896015dfd19e58fc7867d007332456499ec5ceb8a6aa
4
- data.tar.gz: 576d361ebba900b33b2f612b1a0a51d0373cb4a5e1fbd503e43c2bb248ca9306
3
+ metadata.gz: ca479a6c2f4282b97184aee5c65bc94c3607d18ae0a608756737c70ef53407b5
4
+ data.tar.gz: 2294eaa31b51276d48f1a7bc850464c3351a2c93b5d273100192fec4d89561d9
5
5
  SHA512:
6
- metadata.gz: c551c60809be1eefc0d964092adc62a8555cb6f0df3585e775fd2fda1549f1e7d811fb2bfeec992dd3ce872dccb16fd9300a66314d5e51e8f24a98b57f29da5b
7
- data.tar.gz: c96ec13692d6366a916b125266afb70aabcca439594db4e8fd1b434f1db1a88e216938cbf64bc69e64bcfef241ca7314664398c656f1d7333a7480a7d02852f9
6
+ metadata.gz: 6a6c5a434cf90e8e417faed9034e6ea653f1f94b3e373ea00b47045297e73b9e0f58d4619a84f497b45cb3078930da9b4327d7e179540369af7bf7297db2bd05
7
+ data.tar.gz: 4cbcd09254c93d94d1fd06c766ceefb5125990851422db6f6b3af4ad716b7754d71e432ebc88161d938aa5177ec26d6e1f43b7577daa80210e7a493e0266f6ea
data/CHANGELOG.md CHANGED
@@ -8,6 +8,251 @@ 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.9.2 — Policies, audit verbs, zone rename (2026-05-22)
12
+
13
+ ### Breaking — manifest YAML
14
+
15
+ - **Top-level `policies:` block added.** Replaces entry-level `intake.ttl` and
16
+ `intake.on_stale`. Hand-edit existing manifests (see migration recipe below);
17
+ no migrator ships with 0.9.2 because the gem is pre-1.0 with no known
18
+ outside upgraders.
19
+ - **Default zone names renamed.** `canon → identity`, `intake → inbox`,
20
+ `pending → review`, `derived → output`. `working` unchanged. Hand-edit
21
+ the manifest + `mv` the zone directories (see recipe below).
22
+ - Custom-named zones are unaffected.
23
+
24
+ ### Breaking — CLI
25
+
26
+ - `textus stale` removed. Use `textus freshness`.
27
+
28
+ ### Added — verbs
29
+
30
+ - `textus freshness [--prefix=K] [--zone=Z]` — per-entry status (ttl, age,
31
+ next_due_at, status: fresh|stale|never_refreshed|no_policy).
32
+ - `textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X]
33
+ [--correlation-id=ID] [--limit=N]` — query `.textus/audit.log`.
34
+ - `textus blame KEY` — audit rows joined with git commit metadata.
35
+ - `textus policy list` — dump effective policies.
36
+ - `textus policy explain KEY` — show per-slot winners and matching blocks.
37
+
38
+ ### Added — domain
39
+
40
+ - `Textus::Domain::Policy::Refresh` — ttl + on_stale value, exports to
41
+ `Domain::Freshness::Policy`. `on_stale` vocab is `warn | sync | timed_sync`
42
+ (unchanged from 0.9.0).
43
+ - `Textus::Domain::Policy::Promote` — promote_requires predicate.
44
+ - `Textus::Domain::Policy::HandlerAllowlist` — allowed intake handlers.
45
+ - `Textus::Domain::Policy::Matcher` — glob match + specificity ranking.
46
+ - `Textus::Manifest::Policies` — collection over policy blocks with
47
+ most-specific-wins resolution.
48
+
49
+ ### Added — doctor checks
50
+
51
+ - `policy_ambiguity` — two blocks of the same specificity matching one key.
52
+ - `handler_allowlist` — intake handler outside its policy's allowlist.
53
+ - `legacy_intake_fields` — `intake.ttl`/`intake.on_stale` still present in
54
+ raw YAML.
55
+
56
+ ### Unchanged
57
+
58
+ - Wire protocol stays `textus/2`. Envelope shape unchanged.
59
+ - Hook DSL, event names, role gate semantics, schema validation unchanged.
60
+ - `on_stale:` vocabulary (`warn | sync | timed_sync`) and its semantics
61
+ (return-stale / block-and-refresh / try-with-deadline) are unchanged —
62
+ policies merely change where the value lives.
63
+ - `:publish` hook (shipped 0.8.2) remains the extension point for custom
64
+ publish targets.
65
+
66
+ ### Migration recipe (hand-edit, no migrator ships)
67
+
68
+ ```sh
69
+ # In your existing .textus/manifest.yaml:
70
+ # 1. Rename zones[].name fields: canon→identity, intake→inbox,
71
+ # pending→review, derived→output.
72
+ # 2. Rewrite every entries[].zone and entries[].path prefix accordingly.
73
+ # 3. Move each entries[].intake.ttl / on_stale / sync_budget_ms into
74
+ # a new top-level policies:[] block keyed by the entry's exact key:
75
+ #
76
+ # policies:
77
+ # - match: "inbox.news.hn"
78
+ # refresh: { ttl: 6h, on_stale: sync }
79
+
80
+ # On disk:
81
+ mv .textus/zones/canon .textus/zones/identity
82
+ mv .textus/zones/intake .textus/zones/inbox
83
+ mv .textus/zones/pending .textus/zones/review
84
+ mv .textus/zones/derived .textus/zones/output
85
+
86
+ # Verify:
87
+ textus doctor
88
+ ```
89
+
90
+ Find-and-replace tips for ad-hoc references in your own files:
91
+
92
+ ```sh
93
+ # README snippets, CI yaml, shell scripts
94
+ sed -i.bak \
95
+ -e 's/\bcanon\b/identity/g' \
96
+ -e 's/\bintake\b/inbox/g' \
97
+ -e 's/\bpending\b/review/g' \
98
+ -e 's/\bderived\b/output/g' \
99
+ -e 's/textus stale/textus freshness/g' \
100
+ README.md CONTRIBUTING.md
101
+ ```
102
+
103
+ ## 0.9.1 — write-path layering + request Context (2026-05-22)
104
+
105
+ ### Changed — internal architecture (no plugin-visible impact)
106
+
107
+ - Promoted `Store::View` to `Application::Context`. The new Context carries `store`, `role`, `correlation_id`, `clock`, and `dry_run`. It answers `can_read?(zone)` / `can_write?(zone)` via the new `Domain::Permission` value. `Store::View` remains as a deprecated alias for one release; slated for removal in 0.10.0.
108
+ - Extracted `Domain::Permission` from `Manifest#zone_writers`. Pure predicate value — `allows_read?(role)` / `allows_write?(role)`. Manifest gains `#permission_for(zone_name)` returning a `Permission`.
109
+ - Extracted write-path use cases under `Application::Writes::*`:
110
+ - `Writes::Put` (was `Store::Writer#put` orchestration)
111
+ - `Writes::Delete` (was `Store::Writer#delete` orchestration)
112
+ - `Writes::Build` (was `Builder#build` orchestration)
113
+ - `Writes::Accept` (was `Proposal.accept`)
114
+ - `Writes::Publish` (was direct calls to `Publisher.publish`)
115
+ - `Store::Writer#put` and `#delete` reduced to pure I/O (`#write_envelope_to_disk`, `#delete_envelope_from_disk`). The original methods remain as backward-compat shims that delegate to the use cases.
116
+ - `Builder`, `Proposal`, `Publisher` become thin shims. `Publisher` also moved to `Textus::Infra::Publisher` (its prior location remains as an alias).
117
+ - `Store#get`, `#put`, `#delete` reduced to 2-line shims through the new `Composition` module. `Store` itself no longer imports from `Application::*`.
118
+ - New `Composition` factory module wires Contexts and use cases. CLI verbs construct via `Composition.context(store, role:)` then `Composition.<use_case>(ctx).call(...)`.
119
+ - `Refresh::Worker` and `Refresh::Orchestrator` migrated to take `Context` instead of `store:` + `as:`.
120
+
121
+ ### Added
122
+
123
+ - Every event payload now includes `correlation_id` — a UUID generated once per Context. Hook authors can use this to correlate events within a single request (e.g., a `:refreshed` event and a downstream `:built` event share an ID).
124
+
125
+ ### Deprecated
126
+
127
+ - `Textus::Store::View` — use `Textus::Application::Context`. Removed in 0.10.0.
128
+ - `Textus::Publisher` — use `Textus::Infra::Publisher` or `Textus::Application::Writes::Publish`. Removed in 0.10.0.
129
+
130
+ ### Unchanged
131
+
132
+ - Plugin DSL, manifest YAML schema, CLI verb JSON output, envelope fields, event names, wire protocol — all identical to 0.9.0.
133
+ - No migration needed for plugin authors.
134
+
135
+ ## 0.9.0 — intake, event standardization, read-time freshness, layered architecture (2026-05-22)
136
+
137
+ ### Breaking — manifest schema
138
+ - The `source:` block is renamed to `intake:`. Its inner `fetch:` is renamed to `handler:`. Other inner fields (`config:`, `ttl:`) keep their names.
139
+ - Loading a manifest that still uses `source:` raises a clear migration error.
140
+
141
+ ### Breaking — event names (pub-sub bus)
142
+ - `:fetch` → `:intake` (RPC)
143
+ - `:delete` → `:deleted`
144
+ - `:refresh` → `:refreshed`
145
+ - `:build` → `:built`
146
+ - `:publish` → `:published`
147
+ - `:accept` → `:accepted`
148
+ - `:put`, `:mv`, `:reject`, `:loaded` are unchanged (already past tense).
149
+
150
+ ### Breaking — DSL sugar
151
+ - `Textus.fetch(:name)` → `Textus.intake(:name)`
152
+ - `Textus.refresh`, `.build`, `.publish`, `.delete`, `.accept` rename to past-tense equivalents.
153
+ - The primitive `Textus.hook(event, name)` is unchanged — event symbols update per above.
154
+
155
+ ### Added — read-time freshness
156
+ - Every entry's manifest may declare `intake.on_stale: warn | sync | timed_sync` (default `warn`).
157
+ - `warn` — return stale envelope with `stale: true`, `stale_reason: "…"`; no refresh.
158
+ - `sync` — refresh inline, return fresh envelope.
159
+ - `timed_sync` — attempt sync up to `sync_budget_ms` (default 500ms); if exceeded, fork+detach a child to complete the refresh and return stale + `refreshing: true` to the caller. Unix only; on Windows falls back to `warn`.
160
+ - New envelope fields on `textus get`: `stale`, `stale_reason`, `refreshing`.
161
+
162
+ ### Added — refresh lifecycle events
163
+ - `:refresh_began { key, mode }` fires when refresh begins.
164
+ - `:refresh_failed { key, error_class, error_message }` fires on intake errors.
165
+ - `:refresh_detached { key, started_at, budget_ms }` fires when timed_sync gives up waiting and forks.
166
+
167
+ ### Added — actuator
168
+ - `textus refresh-stale [--prefix=KEY] [--zone=Z]` — refreshes every entry whose TTL has expired. Returns `{ refreshed, failed, skipped }` JSON. Exits non-zero on any failure. Intended for cron / CI.
169
+
170
+ ### Added — doctor check
171
+ - `textus doctor` now verifies every manifest `intake.handler:` resolves to a registered `Textus.intake(:name)`, reports missing handlers as errors, and orphan registrations as warnings.
172
+
173
+ ### Changed — internal architecture (no plugin-visible impact)
174
+ - Internals reorganized into four layers: `domain/` (pure values), `application/` (use cases), `infra/` (adapters), and `cli/` (interface). Plugin DSL, manifest schema, CLI verbs, and envelope shape are unchanged.
175
+ - `Freshness` is split into `Domain::Freshness::Evaluator` (pure) + `Domain::Freshness::Policy#decide` (data-driven) + `Application::Refresh::Orchestrator` (effects).
176
+ - `Refresh.call` is now a one-line shim over `Application::Refresh::Worker.run`. `Refresh::Lock` and `Refresh::Detached` moved to `Infra::Refresh`.
177
+ - `Store#get` now routes through `Application::Reads::Get`. `Store::Reader#get` was reduced to pure I/O (`#read_raw_envelope`) — third-party code calling it directly should switch to `Store#get` for freshness annotations.
178
+
179
+ ### Unchanged
180
+ - Wire protocol stays `textus/2`.
181
+ - `:reduce`, `:check`, `:put` unchanged.
182
+ - The recursive `hooks/**/*.rb` loader from 0.8.2.
183
+
184
+ ### Migration
185
+ 1. In `.textus/manifest.yaml`, replace every `source:` with `intake:` and every `fetch:` inside it with `handler:`. No other inner-field renames needed.
186
+ 2. In hook files, replace `Textus.fetch(:name)` with `Textus.intake(:name)` and the other five pub-sub sugar names with their past-tense equivalents.
187
+ 3. (Optional) Add `on_stale: timed_sync` to entries where you want self-healing reads.
188
+ 4. Wire `textus refresh-stale` into cron / GH Actions for scheduled freshness.
189
+ 5. If you subscribed to the `:refresh_started` event during 0.9.0 betas, rename your handler to `Textus.refresh_began(:name)`.
190
+ 6. If you called `Textus::Refresh::Lock` or `Textus::Refresh::Detached` directly (you probably did not), update to `Textus::Infra::Refresh::Lock` / `Textus::Infra::Refresh::Detached`.
191
+
192
+ ## 0.8.3 — :mv, :reject, :loaded events (2026-05-22)
193
+
194
+ ### Added
195
+ - New `:mv` event — fires after a successful `store.mv`. Payload:
196
+ `{ key:, from_key:, to_key:, envelope: }` where `key:` equals `to_key:`
197
+ so `keys:` glob filters route against the entry's post-move home.
198
+ `:put` and `:delete` remain suppressed for renames; `:mv` is the sole signal.
199
+ - New `:reject` event + `store.reject(pending_key, as: "human")` +
200
+ `textus reject KEY --as=human` CLI verb. Counterpart to `:accept` —
201
+ explicitly discards a proposal. Fires `:delete` then `:reject`.
202
+ - New `:loaded` event — fires exactly once at the tail of `Store#initialize`,
203
+ after all hooks are registered and reader/writer are built. Use for cache
204
+ warmups and one-shot setup. Payload: `store:` only.
205
+
206
+ ## 0.8.2 — Hook DSL sugar + :publish event (2026-05-22)
207
+
208
+ ### Added
209
+ - Per-event hook sugar: `Textus.fetch`, `.reduce`, `.check`, `.put`,
210
+ `.delete`, `.refresh`, `.build`, `.accept`, `.publish`. Each takes
211
+ `(name, **opts, &blk)` and delegates to the existing registry. Block
212
+ signatures are per-event (use `**` to absorb unused kwargs).
213
+ - New `:publish` pub-sub event. Fires once per file written to a repo
214
+ path (both for the fixed-list `publish_to:` case and the `publish_each:`
215
+ per-leaf case). Payload: `{ key:, envelope:, source:, target: }`.
216
+ Listeners can react per-file — e.g. `git add` each published file,
217
+ notify on writes, compute checksums.
218
+ - `.textus/hooks/**/*.rb` — hook files in subdirectories are now loaded.
219
+ Subdirectory names are organizational; the registered event and name
220
+ come from the DSL call, not the file path. Files load in alphabetical
221
+ order by full path.
222
+
223
+ ### Unchanged
224
+ - `Textus.hook(:event, :name, &blk)` primitive — still works, still the
225
+ authoritative entry point.
226
+ - `:build` event semantics — still fires once per derived entry.
227
+ - Registry shape, dispatcher behavior, audit log, wire protocol
228
+ (`textus/2`), envelope shape.
229
+
230
+ ### Example migrations
231
+ The bundled `examples/claude-plugin` was migrated to the new DSL
232
+ (snake_case names, sugar methods). No behavioral change; serves as the
233
+ canonical example.
234
+
235
+ ## 0.8.1 — Terminology cleanup (2026-05-21)
236
+
237
+ ### Breaking — intro output
238
+ - `textus intro` JSON: the `"extensions"` key is renamed to `"hooks"`. Consumers
239
+ reading `env["extensions"]` must switch to `env["hooks"]`. Wire protocol
240
+ remains `textus/2`; envelope shape on read/write is unchanged.
241
+
242
+ ### Internal Ruby renames
243
+ - `Textus::Store#load_extensions` → `Textus::Store#load_hooks`.
244
+ - `Textus::Intro.extensions_for` → `Textus::Intro.hooks_for`.
245
+ - Error string `"failed loading extension <file>"` → `"failed loading hook <file>"`.
246
+
247
+ ### Fixed
248
+ - `textus doctor` `:check`-hook failure hint pointed to `.textus/extensions/`,
249
+ which has never existed in 0.6+. Now correctly points to `.textus/hooks/`.
250
+
251
+ ### Docs
252
+ - SPEC.md §5.10: "single extension verb" → "single hook verb".
253
+ - Scaffolded `.textus/hooks/README.md` no longer mixes "hook" and "extension"
254
+ terminology.
255
+
11
256
  ## 0.8.0 — Folder restructure & Zeitwerk autoload (2026-05-21)
12
257
 
13
258
  ### Breaking — internal Ruby renames
data/README.md CHANGED
@@ -14,7 +14,7 @@ Reference implementation in Ruby. Wire format `textus/2`. SPEC: [`SPEC.md`](SPEC
14
14
  Two versions, deliberately independent:
15
15
 
16
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.
17
+ - **Gem version:** semver, currently `0.9.2`. 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
 
@@ -45,18 +45,20 @@ You get `.textus/` with all five zone directories, baseline schemas, an empty au
45
45
  audit.log # append-only NDJSON, every write
46
46
  schemas/ # YAML field shapes per entry family
47
47
  templates/ # mustache templates for derived entries
48
- extensions/ # one .rb per action / reducer / hook / doctor_check
48
+ hooks/ # one .rb per hook
49
49
  sentinels/ # publish bookkeeping
50
50
  zones/
51
- canon/ # human-only — identity, voice, decisions
51
+ identity/ # human-only — identity, voice, decisions
52
52
  working/ # human / ai / script — day-to-day catalog
53
- intake/ # script — declared external inputs (actions)
54
- pending/ # ai + human — proposals awaiting accept
55
- derived/ # build only — computed outputs
53
+ inbox/ # script — declared external inputs (actions)
54
+ review/ # ai + human — proposals awaiting accept
55
+ output/ # build only — computed outputs
56
56
  ```
57
57
 
58
58
  Manifest `path:` fields are relative to `.textus/zones/`. So `working.network.org.jane` lives at `.textus/zones/working/network/org/jane.md`.
59
59
 
60
+ > **Renamed in 0.9.2.** Pre-0.9.2 defaults were `canon`, `intake`, `pending`, `derived`. `working` is unchanged. Upgrading a 0.9.1 store is a hand-edit (see CHANGELOG migration recipe).
61
+
60
62
  Read and write:
61
63
 
62
64
  ```sh
@@ -64,7 +66,9 @@ textus get working.network.org.jane
64
66
  textus list --zone=working
65
67
  echo '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
66
68
  | textus put working.network.org.bob --as=human --stdin
67
- textus stale --zone=derived
69
+ textus freshness --zone=output # per-entry fresh/stale/never_refreshed/no_policy
70
+ textus policy list # show every policy block
71
+ textus audit --limit=20 # query the audit log
68
72
  ```
69
73
 
70
74
  (All verbs return JSON envelopes by default; pass `--format=json` explicitly if you prefer.)
@@ -77,8 +81,8 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
77
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.
78
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.
79
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.
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.
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.
84
+ - **`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.
85
+ - **`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.
82
86
  - **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.
83
87
 
84
88
  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.
@@ -91,34 +95,38 @@ All verbs accept `--format=json` and return the envelope defined in SPEC §8. Wr
91
95
 
92
96
  | Verb | Purpose |
93
97
  |---|---|
94
- | `intro` | Store orientation: zones, entries, extensions, write flows, CLI map |
98
+ | `intro` | Store orientation: zones, entries, hooks, write flows, CLI map |
95
99
  | `list [--prefix=K] [--zone=Z]` | Enumerate keys |
96
100
  | `where K` | Resolve a key to its filesystem path |
97
101
  | `get K` | Full envelope (frontmatter, body, uid, etag, format) |
98
102
  | `schema show K` | Schema bound to an entry |
99
- | `stale [--prefix=K] [--zone=Z]` | List stale derived/intake entries |
103
+ | `freshness [--prefix=K] [--zone=Z]` | Per-entry status (fresh / stale / never_refreshed / no_policy) against `policies:` |
104
+ | `audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]` | Query `.textus/audit.log` |
105
+ | `blame KEY` | Audit rows joined with git commit metadata |
106
+ | `policy list` / `policy explain KEY` | Dump effective policies / per-slot winners for one key |
100
107
  | `deps K` / `rdeps K` | Forward / reverse projection dependencies |
101
108
  | `published` | List `publish_to:` targets and their backing keys |
102
109
  | `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) |
110
+ | `hook list [--event=E]` | Registered hooks grouped by event (intake, reduce, check, put, deleted, refreshed, built, accepted, published, mv, reject, loaded, refresh_began, refresh_failed, refresh_detached) |
104
111
 
105
112
  **Write:**
106
113
 
107
114
  | Verb | Role |
108
115
  |---|---|
109
116
  | `put K --stdin --as=R [--action=NAME]` | per zone |
110
- | `hook run NAME [--key=val] [--as=R]` | per zone written (invoke a registered fetch hook) |
117
+ | `hook run NAME [--key=val] [--as=R]` | per zone written (invoke a registered intake hook) |
111
118
  | `delete K --if-etag=E --as=R` | per zone |
112
119
  | `refresh K --as=script` | per zone (typically `script`) |
113
120
  | `key mv old new --as=R [--dry-run]` | per zone (same-zone moves; uid preserved) |
114
121
  | `build [--prefix=K] [--dry-run]` | `build` |
115
122
  | `accept K --as=human` | `human` only |
123
+ | `reject K --as=human` | `human` only (discards a pending proposal; fires `:reject`) |
116
124
 
117
125
  **Health & maintenance:**
118
126
 
119
127
  | Verb | Purpose |
120
128
  |---|---|
121
- | `doctor` | 8 health checks; `ok: true` when clean |
129
+ | `doctor` | Health checks (manifest, schemas, templates, hooks, illegal keys, sentinels, audit log, policy ambiguity, handler allowlist, legacy intake fields); `ok: true` when clean |
122
130
  | `key migrate [--dry-run]` | Rename files whose basenames violate the strict key grammar |
123
131
 
124
132
  **Scaffolding (human-only):**
@@ -134,11 +142,11 @@ All verbs accept `--format=json` and return the envelope defined in SPEC §8. Wr
134
142
 
135
143
  | Zone | `writable_by` | Purpose |
136
144
  |---|---|---|
137
- | `canon` | `[human]` | Identity, voice, decisions — slow-changing |
145
+ | `identity` | `[human]` | Identity, voice, decisions — slow-changing |
138
146
  | `working` | `[human, ai, script]` | Active project state |
139
- | `intake` | `[script]` | Declared external inputs (actions) |
140
- | `pending` | `[ai, human]` | AI proposals; humans run `textus accept` to apply |
141
- | `derived` | `[build]` | Computed outputs from `textus build` |
147
+ | `inbox` | `[script]` | Declared external inputs (actions) |
148
+ | `review` | `[ai, human]` | AI proposals; humans run `textus accept` to apply |
149
+ | `output` | `[build]` | Computed outputs from `textus build` |
142
150
 
143
151
  Mismatches return `write_forbidden` with a hint naming the role that *would* be allowed. Every write records the resolved role in `.textus/audit.log`.
144
152
 
@@ -150,20 +158,40 @@ Derived entries declare a `projection:` (`select`, `pluck`, `sort_by`, `limit`,
150
158
 
151
159
  ## Extension points
152
160
 
153
- textus exposes one DSL verb:
161
+ textus exposes a hook DSL. Drop `.rb` files into `.textus/hooks/` (subdirectories are fine; files load alphabetically by full path). Events:
162
+
163
+ - `:intake` — bring bytes in from elsewhere (returns `{_meta:, body:}`)
164
+ - `:reduce` — transform rows during projection (returns rows)
165
+ - `:check` — custom doctor check (returns issues)
166
+ - `:put`, `:deleted`, `:refreshed`, `:built`, `:accepted`, `:published`, `:mv`, `:reject`, `:loaded` — react to lifecycle events
167
+ - `:refresh_began`, `:refresh_failed`, `:refresh_detached` — background-refresh lifecycle (0.9.0+)
154
168
 
155
169
  ```ruby
156
- Textus.hook(event, name, **opts) { |args| ... }
170
+ # Inside .textus/hooks/local_file.rb
171
+ Textus.intake(:local_file) do |config:, args:, **|
172
+ path = config["path"] or raise "local-file requires intake.config.path"
173
+ {
174
+ _meta: { "last_refreshed_at" => Time.now.utc.iso8601, "source_path" => path },
175
+ body: File.read(File.expand_path(path)),
176
+ }
177
+ end
157
178
  ```
158
179
 
159
- Drop `.rb` files into `.textus/hooks/`. Events:
180
+ ```ruby
181
+ Textus.reduce(:rank_by_recency) do |rows:, **|
182
+ rows.sort_by { |r| r["updated_at"].to_s }.reverse
183
+ end
184
+ ```
160
185
 
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
186
+ To keep a batch of stale intake entries current in one shot:
187
+
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
192
+ ```
165
193
 
166
- See SPEC.md §5.10 for the full contract.
194
+ The primitive `Textus.hook(event, name, **opts) { ... }` is also supported. See SPEC.md §5.10 for the full contract.
167
195
 
168
196
  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.
169
197