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
data/SPEC.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # textus/2 — Specification
2
2
 
3
- **Status:** Draft v2.0 (2026-05-19)
3
+ **Status:** Draft v2.0 (2026-05-22, updated for 0.9.2)
4
4
  **Protocol identifier:** `textus/2`
5
5
  **Reference implementation:** Ruby gem `textus`
6
6
 
@@ -62,11 +62,11 @@ The root is `.textus/` at the project working directory. A typical v1.0 tree:
62
62
  templates/ # internal: Mustache templates referenced by derived entries
63
63
  parsers/ # internal: project-local parser extensions
64
64
  zones/ # ALL user content lives here
65
- canon/ # zone: canon (human-only)
65
+ identity/ # zone: identity (human-only)
66
66
  working/ # zone: working (human, ai, script)
67
- intake/ # zone: intake (script — declared external inputs)
68
- pending/ # zone: pending (ai proposals awaiting accept)
69
- derived/ # zone: derived (build only — computed outputs)
67
+ inbox/ # zone: inbox (script — declared external inputs)
68
+ review/ # zone: review (ai/human proposals awaiting accept)
69
+ output/ # zone: output (build only — computed outputs)
70
70
  ```
71
71
 
72
72
  Textus internals (`manifest.yaml`, `audit.log`, `role`, `schemas/`, `templates/`, `parsers/`) live directly under `.textus/`. **All user content lives under `.textus/zones/`.** Manifest `path:` fields are relative to `.textus/zones/` — they do **not** include the `zones/` prefix. Implementations MUST prepend `zones/` to every `path:` when resolving a key to a filesystem location.
@@ -94,21 +94,21 @@ The manifest declares: (a) which zones exist and which roles may write to each,
94
94
  version: textus/2
95
95
 
96
96
  zones:
97
- - name: canon
97
+ - name: identity
98
98
  writable_by: [human]
99
99
  - name: working
100
100
  writable_by: [human, ai, script]
101
- - name: intake
101
+ - name: inbox
102
102
  writable_by: [script]
103
- - name: pending
104
- writable_by: [ai]
105
- - name: derived
103
+ - name: review
104
+ writable_by: [ai, human]
105
+ - name: output
106
106
  writable_by: [build]
107
107
 
108
108
  entries:
109
- - key: canon.identity
110
- path: canon/identity.md
111
- zone: canon
109
+ - key: identity.self
110
+ path: identity/self.md
111
+ zone: identity
112
112
  schema: identity
113
113
 
114
114
  - key: working.network.org
@@ -118,13 +118,19 @@ entries:
118
118
  owner: textus:network
119
119
  nested: true
120
120
 
121
- - key: derived.catalogs.people
122
- path: derived/catalogs/people.md
123
- zone: derived
121
+ - key: output.catalogs.people
122
+ path: output/catalogs/people.md
123
+ zone: output
124
124
  schema: null
125
125
  owner: textus:build
126
+
127
+ policies:
128
+ - match: inbox.**
129
+ refresh: { ttl: 6h, on_stale: warn }
126
130
  ```
127
131
 
132
+ **Note (0.9.2):** the default zone names were renamed from `canon|intake|pending|derived` to `identity|inbox|review|output` to align with one lifecycle axis. `working` is unchanged. Existing stores migrate by hand-editing the manifest and `mv`-ing the zone directories (see CHANGELOG). The names are conventional — the manifest is the source of truth for write permissions; rename freely.
133
+
128
134
  **Backward compatibility.** If the manifest omits the `zones:` block, the legacy v0.1 three-zone model is synthesized:
129
135
 
130
136
  ```yaml
@@ -184,11 +190,11 @@ Each zone declares which **roles** may write to it via `writable_by:` in the man
184
190
 
185
191
  | Zone | `writable_by` | Use case |
186
192
  |---|---|---|
187
- | `canon` | `[human]` | Identity, voice, immutable principles — things only a human edits. |
193
+ | `identity` | `[human]` | Identity, voice, immutable principles — things only a human edits. (`canon` pre-0.9.2.) |
188
194
  | `working` | `[human, ai, script]` | Active project state: notes, decisions, network — what humans and agents update day-to-day. |
189
- | `intake` | `[script]` | Declared external inputs (calendar, feeds, scraped pages). Refreshed by external runner scripts; never by humans or AI directly. |
190
- | `pending` | `[ai]` | AI-generated proposals awaiting human review via `textus accept`. Lets agents stage changes without touching `working`. |
191
- | `derived` | `[build]` | Computed outputs (catalogs, indexes, published context). Written only by the build runner via `textus build`. |
195
+ | `inbox` | `[script]` | Declared external inputs (calendar, feeds, scraped pages). Refreshed by external runner scripts; never by humans or AI directly. (`intake` pre-0.9.2.) |
196
+ | `review` | `[ai, human]` | AI-generated proposals awaiting human review via `textus accept`. Lets agents stage changes without touching `working`. (`pending` pre-0.9.2.) |
197
+ | `output` | `[build]` | Computed outputs (catalogs, indexes, published context). Written only by the build runner via `textus build`. (`derived` pre-0.9.2.) |
192
198
 
193
199
  A write is gated by the caller's **role**, supplied via `--as=<role>`. If the role is not in the target zone's `writable_by` list, the write returns `write_forbidden`.
194
200
 
@@ -250,36 +256,54 @@ A sentinel is written for each published file at `<store_root>/sentinels/<target
250
256
 
251
257
  **Per-leaf publishing.** A nested entry MAY declare `publish_each:` instead of `publish_to:` (see §4). When the build runs, every leaf reachable under the nested entry is byte-copied to the path produced by substituting `{leaf}` / `{basename}` / `{key}` / `{ext}` in the template, with a sentinel written under `<store_root>/sentinels/` at the mirrored target path. The build envelope grows a `published_leaves` array — one row per leaf, with `key`, `source`, and `target` — alongside the existing `built` array. Targets that would resolve outside the repo root are refused.
252
258
 
253
- ### 5.4 Intake (declared, refreshed via registered fetch hook)
259
+ ### 5.4 Intake (declared, refreshed via registered intake handler)
254
260
 
255
- Intake entries declare an external source by naming a **fetch hook** — a registered, named function that pulls data into the entry. textus itself still makes no implicit network calls: a fetch hook only runs in intake mode when explicitly invoked by `textus refresh KEY --as=script`. The declaration is data only:
261
+ Intake entries declare an external source by naming an **intake handler** — a registered, named function that pulls data into the entry. textus itself still makes no implicit network calls: an intake handler only runs when explicitly invoked by `textus refresh KEY --as=script` (or by `textus refresh-stale`). The declaration is data only:
256
262
 
257
263
  ```yaml
258
- - key: intake.calendar.events
259
- zone: intake
260
- source:
261
- fetch: ical-events
264
+ - key: inbox.calendar.events
265
+ zone: inbox
266
+ intake:
267
+ handler: ical-events
262
268
  config:
263
269
  url: "https://calendar.google.com/.../basic.ics"
264
- ttl: 6h
270
+
271
+ policies:
272
+ - match: inbox.calendar.**
273
+ refresh:
274
+ ttl: 6h
275
+ on_stale: warn # warn | sync | timed_sync (default: warn)
276
+ sync_budget_ms: 500 # only used when on_stale: timed_sync (default: 500)
265
277
  ```
266
278
 
267
- `fetch` names a registered `:fetch` hook (see §5.10 for the hook contract); `config` is an opaque hash handed to the hook; `ttl` is the staleness budget. Implementations MUST reject legacy `source.from`, `source.parse`, `source.fetcher`, and `source.action` with a clear usage error.
279
+ `handler` names a registered `:intake` hook (see §5.10 for the hook contract); `config` is an opaque hash handed to the handler. The freshness budget (`ttl`, `on_stale`, `sync_budget_ms`) lives in a top-level **`policies:`** block matched by key glob (§5.11). Implementations MUST reject legacy `intake.ttl` / `intake.on_stale` / `intake.sync_budget_ms` at manifest load with a clear migration message pointing at the top-level `policies:` block (see the 0.9.2 CHANGELOG for a hand-edit recipe). Implementations MUST also reject legacy `source.from`, `source.parse`, `source.fetcher`, `source.action`, and `source.fetch` with a usage error pointing at the `intake:` key.
280
+
281
+ #### `on_stale:` semantics
282
+
283
+ `on_stale:` declares what happens when `textus get` (or any read path that annotates freshness) encounters a stale intake entry. The value lives on the matching policy block, not on the entry. Vocabulary unchanged across 0.9.x: `warn | sync | timed_sync`.
268
284
 
269
- In intake mode the hook MUST return one of three shapes, all normalized by the store into its internal `{_meta, body, content}` representation (§5.12):
285
+ | Value | Behaviour |
286
+ |---|---|
287
+ | `warn` (default) | Return the entry immediately with `stale: true`, `stale_reason:` populated, and `refreshing: false`. No blocking. |
288
+ | `sync` | Block the `get` call, run the intake handler in-process, write the refreshed result, then return the fresh envelope. The caller waits. |
289
+ | `timed_sync` | Like `sync`, but with a `sync_budget_ms` deadline (default 500 ms). If the handler finishes within the budget the fresh envelope is returned. If it does not finish in time, return the stale envelope (with `stale: true`, `refreshing: true`) and let the refresh complete in the background. Fires `:refresh_detached` when the deadline is exceeded. |
290
+
291
+ > **Note:** `list`/`where` paths do **not** annotate freshness in 0.9.0 — only `get` does. Known limitation; full `list` freshness annotation is planned for 0.10.
292
+
293
+ In intake mode the handler MUST return one of three shapes, all normalized by the store into its internal `{_meta, body, content}` representation (§5.12):
270
294
 
271
295
  - `{ _meta:, body: }` — markdown-friendly; `_meta` becomes the entry's parsed metadata hash.
272
296
  - `{ content: }` — for `format: json|yaml` entries; the parsed object becomes the entry's content.
273
297
  - `{ body: }` — raw bytes for `text` or for any format that prefers verbatim writes; the store re-parses and validates per `format:`.
274
298
 
275
- **Built-in fetch hooks.** `json`, `csv`, `markdown-links`, `ical-events`, `rss` are always available. They expect raw bytes in `config["bytes"]` and produce structured `_meta`/body. Built-ins do not perform I/O themselves — the caller (or an outer hook) is responsible for supplying bytes.
299
+ **Built-in intake handlers.** `json`, `csv`, `markdown-links`, `ical-events`, `rss` are always available. They expect raw bytes in `config["bytes"]` and produce structured `_meta`/body. Built-ins do not perform I/O themselves — the caller (or an outer hook) is responsible for supplying bytes.
276
300
 
277
301
  **Refresh paths.** Two are supported:
278
302
 
279
- 1. **In-process** — `textus refresh KEY --as=script` resolves the entry's `source.fetch`, invokes the registered `:fetch` hook with `(config:, store:, args: {})`, and writes the result under role `script`.
280
- 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`.
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.
281
305
 
282
- Both paths share the same role gate, audit-log entry, and `:refresh` event. User-supplied hooks live in `.textus/hooks/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
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.
283
307
 
284
308
  ### 5.5 Pending / accept workflow
285
309
 
@@ -365,22 +389,53 @@ Reducers are RPC hooks on the `:reduce` event. See §5.10.
365
389
 
366
390
  ### 5.10 Hooks
367
391
 
368
- textus has a single extension verb: `Textus.hook(event, name, **opts) { ... }`. The EVENTS table below defines every extension point. Files in `.textus/hooks/*.rb` are `load`ed at `Store#initialize` in lexical order.
392
+ textus has a single hook verb: `Textus.hook(event, name, **opts) { ... }`. The EVENTS table below defines every extension point. Files in `.textus/hooks/**/*.rb` are `load`ed at `Store#initialize` in alphabetical order by full path.
393
+
394
+ The subdirectory layout under `hooks/` is organizational only; the registered event and name come from the DSL call, not the file path. Files are loaded in alphabetical order by full path.
395
+
396
+ #### Sugar surface (0.8.2+)
397
+
398
+ Per-event methods are provided for ergonomics. They delegate to the same registry as `Textus.hook`.
369
399
 
370
- | Event | Mode | Args | Return | Failure |
371
- |----------|---------|-----------------------------------|---------------|---------|
372
- | :fetch | rpc | store:, config:, args: | {_meta:, body:} | aborts op |
373
- | :reduce | rpc | store:, rows:, config: | rows array | aborts op |
374
- | :check | rpc | store: | issues array | aborts doctor |
375
- | :put | pubsub | store:, key:, envelope: | (discarded) | logged |
376
- | :delete | pubsub | store:, key: | (discarded) | logged |
377
- | :refresh | pubsub | store:, key:, envelope:, change: | (discarded) | logged |
378
- | :build | pubsub | store:, key:, envelope:, sources: | (discarded) | logged |
379
- | :accept | pubsub | store:, key:, target_key: | (discarded) | logged |
400
+ ```ruby
401
+ Textus.intake(:my_source) { |config:, args:, **| … }
402
+ Textus.reduce(:rank_by_recency) { |rows:, **| }
403
+ Textus.check(:storage_writable) { |store:| … }
404
+ Textus.put(:audit, keys: ["working.*"]) { |key:, envelope:, **| }
405
+ Textus.published(:git_add, keys: ["derived.*"]) { |target:, **| `git add #{target.shellescape}` }
406
+ ```
407
+
408
+ The primitive `Textus.hook(:event, :name, &blk)` remains supported and is the authoritative entry point; sugar methods are thin wrappers.
409
+
410
+ | Event | Mode | Args | Return | Failure |
411
+ |---------------------|---------|-----------------------------------------------------------|-----------------------|---------------|
412
+ | :intake | rpc | store:, config:, args: | {_meta:, body:} | aborts op |
413
+ | :reduce | rpc | store:, rows:, config: | rows array | aborts op |
414
+ | :check | rpc | store: | issues array | aborts doctor |
415
+ | :put | pubsub | store:, key:, envelope: | (discarded) | logged |
416
+ | :deleted | pubsub | store:, key: | (discarded) | logged |
417
+ | :refreshed | pubsub | store:, key:, envelope:, change: | (discarded) | logged |
418
+ | :built | pubsub | store:, key:, envelope:, sources: | (discarded) | logged |
419
+ | :accepted | pubsub | store:, key:, target_key: | (discarded) | logged |
420
+ | :published | pubsub | store:, key:, envelope:, source:, target: | (discarded) | logged |
421
+ | :mv | pubsub | store:, key:, from_key:, to_key:, envelope: | (discarded) | logged |
422
+ | :reject | pubsub | store:, key:, target_key: | (discarded) | logged |
423
+ | :loaded | pubsub | store: | (discarded) | logged |
424
+ | :refresh_began | pubsub | store:, key:, mode: | (discarded) | logged |
425
+ | :refresh_failed | pubsub | store:, key:, error_class:, error_message: | (discarded) | logged |
426
+ | :refresh_detached | pubsub | store:, key:, started_at:, budget_ms: | (discarded) | logged |
427
+
428
+ **New in 0.9.0:** `:intake` replaces `:fetch` as the RPC event name for intake handlers. `:deleted`, `:refreshed`, `:built`, `:accepted`, `:published` replace `:delete`, `:refresh`, `:build`, `:accept`, `:publish` respectively for all pub-sub callers. The three `:refresh_*` lifecycle events report the progress and failures of background (timed_sync) refreshes.
429
+
430
+ **`:refresh_began`** fires immediately before an intake handler is invoked. `mode:` is one of `"sync"` or `"timed_sync"`.
431
+
432
+ **`:refresh_failed`** fires when an intake handler raises. `error_class:` is the exception class name string; `error_message:` is `e.message`.
380
433
 
381
- **Signature invariant** every hook receives `store:` as its first keyword argument. Event-specific kwargs follow in stable left-to-right order. The primary entity is always `key:` (for `:accept`, `key:` is the pending key being accepted and `target_key:` is the destination).
434
+ **`:refresh_detached`** fires when a `timed_sync` refresh exceeds its budget and is handed off to a background thread. `started_at:` is an ISO-8601 UTC string; `budget_ms:` is the configured deadline as an integer.
382
435
 
383
- **RPC mode** — exactly one handler per (event, name). The manifest references the handler by name (`source.fetch: NAME`, `projection.reduce: NAME`). Failure or timeout aborts the calling operation.
436
+ **Signature invariant** — every hook receives `store:` as its first keyword argument. Event-specific kwargs follow in stable left-to-right order. The primary entity is always `key:` (for `:accepted`, `key:` is the pending key being accepted and `target_key:` is the destination). For `:mv`, `key:` is present and equals `to_key:` — it is the entry's post-move home, present so `keys:` glob filters route correctly; `from_key:` is the prior key. For `:reject`, `key:` is the pending key being rejected. For `:loaded`, no key the event observes store readiness, not an entry.
437
+
438
+ **RPC mode** — exactly one handler per (event, name). The manifest references the handler by name (`intake.handler: NAME`, `projection.reduce: NAME`). Failure or timeout aborts the calling operation.
384
439
 
385
440
  **Pub-sub mode** — zero or more handlers per event. All matching handlers fire. The `keys:` option restricts a handler to keys matching one of the given globs (`File.fnmatch?` rules). Absence of `keys:` fires on every event of that type. Handler failures and 2s timeouts are logged to `audit.log` as `event_error` rows; they NEVER abort the triggering operation.
386
441
 
@@ -388,6 +443,40 @@ The `store:` argument is always a read-only store proxy. Write attempts raise `U
388
443
 
389
444
  Each handler runs under `Timeout.timeout(2)`.
390
445
 
446
+ ### 5.11 Policies (v0.9.2)
447
+
448
+ A manifest MAY declare a top-level `policies:` block — a list of rule blocks matched against entry keys by glob. Each block carries one or more slots:
449
+
450
+ ```yaml
451
+ policies:
452
+ - match: inbox.**
453
+ refresh: { ttl: 6h, on_stale: warn }
454
+
455
+ - match: inbox.calendar.**
456
+ refresh: { ttl: 30m, on_stale: timed_sync, sync_budget_ms: 800 }
457
+ handler_allowlist: [ical-events]
458
+
459
+ - match: review.**
460
+ promote_requires: [human-review]
461
+ ```
462
+
463
+ **Slots (all optional within a block):**
464
+
465
+ | Slot | Type | Meaning |
466
+ |---|---|---|
467
+ | `refresh` | `{ ttl, on_stale, sync_budget_ms }` | Freshness budget for intake entries (formerly `intake.ttl` / `intake.on_stale` / `intake.sync_budget_ms`). `on_stale` is `warn` (default), `sync`, or `timed_sync`. |
468
+ | `handler_allowlist` | list of strings | Constrains which `intake.handler:` names may be used by entries matched by this block. Enforced by `textus doctor`. |
469
+ | `promote_requires` | list of strings | Predicates a `review` entry must satisfy before `textus accept` will promote it. Implementations MAY use a built-in or hook-resolved predicate. Reserved for future enforcement; recorded today. |
470
+ | `retention` | (reserved) | Slot reserved for future retention policy (cap by age / count). Implementations parse it but otherwise ignore. |
471
+
472
+ **Match grammar.** `match:` is a single glob using `*` (single segment) and `**` (any depth). A literal segment ranks more specifically than `*`; `*` ranks more specifically than `**`.
473
+
474
+ **Resolution.** For each key textus computes a `PolicySet { refresh, handler_allowlist, promote, retention }` by walking every block whose `match` matches the key, ranked by specificity. **Per slot, the most specific block wins.** Two blocks of equal specificity that match the same key and fill the same slot is a manifest error reported by `textus doctor` (`policy_ambiguity`).
475
+
476
+ **Read surface.** `textus policy list` dumps every block. `textus policy explain KEY` shows the resolved `PolicySet` for one key plus which block won each slot.
477
+
478
+ **Migration.** No migrator ships in 0.9.2 — the gem is pre-1.0 with no known outside upgraders. Existing 0.9.1 stores hand-edit the manifest to move each entry's legacy `intake.ttl` / `intake.on_stale` / `intake.sync_budget_ms` into a top-level `policies:` block matched by the entry's exact key. See the 0.9.2 CHANGELOG for the recipe.
479
+
391
480
  ### 5.12 Storage formats (v1.2)
392
481
 
393
482
  An entry's `format:` selects a storage strategy. All strategies expose the same `parse(bytes) → {_meta, body, content}` and `serialize(meta:, body:, content:) → bytes` contract. The store, audit, etag, and projection layers operate on the parsed shape; only (de)serialization differs.
@@ -474,14 +563,17 @@ Every successful CLI response (`--format=json`) is a single JSON envelope:
474
563
  "body": "Short body in Markdown.\n",
475
564
  "etag": "sha256:8f3c…",
476
565
  "schema_ref": "person",
477
- "uid": "a1b2c3d4e5f60718"
566
+ "uid": "a1b2c3d4e5f60718",
567
+ "stale": false,
568
+ "stale_reason": null,
569
+ "refreshing": false
478
570
  }
479
571
  ```
480
572
 
481
573
  **Field rules:**
482
574
  - `protocol` MUST be the exact string `textus/2`.
483
575
  - `key` MUST be the canonical resolved key.
484
- - `zone` MUST be one of the zones declared in the manifest (`canon`, `working`, `intake`, `pending`, `derived` for the default v1.0 model; legacy v0.1 manifests synthesize `fixed`, `state`, `derived` per §4).
576
+ - `zone` MUST be one of the zones declared in the manifest (`identity`, `working`, `inbox`, `review`, `output` for the default 0.9.2 model; legacy v0.1 manifests synthesize `fixed`, `state`, `derived` per §4).
485
577
  - `path` MUST be an absolute filesystem path.
486
578
  - `format` MUST be one of `markdown`, `json`, `yaml`, `text` (§5.12). Absent envelopes are treated as `markdown` for back-compat.
487
579
  - `body` is the raw on-disk bytes as a UTF-8 string for every format.
@@ -489,6 +581,11 @@ Every successful CLI response (`--format=json`) is a single JSON envelope:
489
581
  - `etag` MUST be `sha256:<hex>` of the raw file bytes, computed identically for every format.
490
582
  - `schema_ref` MAY be `null` for entries in subtrees with `schema: null`.
491
583
  - `uid` is the stable Textus UID (§7) if the entry carries one, else `null`. Always present in the envelope.
584
+ - `stale` is `true` when the entry's TTL has elapsed and the data has not yet been refreshed; `false` otherwise. Only populated for entries matched by a `refresh:` policy slot (typically `inbox` zone); always `false` elsewhere. (0.9.0+; resolves through `policies:` since 0.9.2.)
585
+ - `stale_reason` is a short human-readable string describing why the entry is stale (e.g. `"ttl_exceeded"`, `"never_refreshed"`), or `null` when `stale` is `false`. (0.9.0+)
586
+ - `refreshing` is `true` when a `timed_sync` background refresh is in flight for this entry; `false` otherwise. Callers observing `stale: true, refreshing: true` SHOULD retry after a short delay. (0.9.0+)
587
+
588
+ > **Note:** `list`/`where` envelopes do **not** include `stale`, `stale_reason`, or `refreshing` in 0.9.0 — freshness annotation is only provided by `get`. This is a known limitation.
492
589
 
493
590
  Errors use a distinct envelope:
494
591
 
@@ -522,11 +619,14 @@ All verbs accept `--format=json` and emit a canonical envelope (success or error
522
619
 
523
620
  | Verb | Reads / writes | Role required |
524
621
  |---|---|---|
525
- | `list [--prefix=K] [--zone=Z] [--stale]` | read | any |
622
+ | `list [--prefix=K] [--zone=Z]` | read | any |
526
623
  | `where K` | read | any |
527
624
  | `get K` | read | any |
528
625
  | `schema show K` | read | any |
529
- | `stale [--prefix=K] [--strict]` | read | any |
626
+ | `freshness [--prefix=K] [--zone=Z]` | read | any |
627
+ | `audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]` | read | any |
628
+ | `blame KEY` | read | any |
629
+ | `policy list` / `policy explain KEY` | read | any |
530
630
  | `deps K` / `rdeps K` | read | any |
531
631
  | `published` | read | any |
532
632
  | `hook list` | read | any |
@@ -535,6 +635,7 @@ All verbs accept `--format=json` and emit a canonical envelope (success or error
535
635
  | `put K --stdin --as=R [--fetch=NAME]` | write | per zone |
536
636
  | `delete K --if-etag=E --as=R` | write | per zone |
537
637
  | `refresh K --as=script` | write | per zone (typically `script`) |
638
+ | `refresh-stale [--prefix=K] [--zone=Z] [--as=script]` | write | per zone (typically `script`) |
538
639
  | `build [--prefix=K] [--dry-run]` | write | `build` (default) |
539
640
  | `accept K --as=human` | write | `human` |
540
641
  | `init` | write | `human` |
@@ -544,6 +645,8 @@ All verbs accept `--format=json` and emit a canonical envelope (success or error
544
645
  | `key uid K` | read | any |
545
646
  | `hook run NAME` | write | any |
546
647
 
648
+ **0.9.2 breaking:** `textus stale` was removed; use `textus freshness` (same input, slightly richer output shape — see below).
649
+
547
650
  **`put` input** (read from stdin when `--stdin` is given):
548
651
 
549
652
  ```json
@@ -554,19 +657,25 @@ All verbs accept `--format=json` and emit a canonical envelope (success or error
554
657
 
555
658
  `if_etag` is optional on `put`, required on `delete`. When provided, the write fails with `etag_mismatch` if the on-disk file's etag differs. When omitted on `put`, the write is unconditional (last-writer-wins).
556
659
 
557
- **`textus stale` output shape:**
660
+ **`textus freshness` output shape:**
558
661
 
559
662
  ```json
560
- [
561
- { "key": "derived.catalogs.skills",
562
- "path": "/abs/.textus/zones/derived/catalogs/skills.md",
563
- "generator": { "command": "rake catalog:skills",
564
- "sources": ["working.projects", "working.network"] },
565
- "reason": "source 'working.projects' modified after generated.at" }
566
- ]
663
+ {
664
+ "verb": "freshness",
665
+ "rows": [
666
+ { "key": "inbox.upstream.notes",
667
+ "zone": "inbox",
668
+ "last_refreshed_at": "2026-05-21T13:21:17Z",
669
+ "age_seconds": 65000,
670
+ "ttl_seconds": 43200,
671
+ "on_stale": "warn",
672
+ "status": "stale",
673
+ "next_due_at": "2026-05-22T01:21:17Z" }
674
+ ]
675
+ }
567
676
  ```
568
677
 
569
- `textus build` consumes the stale list and executes each `generator.command` itself, writing results back through `put` under the `build` role. `--dry-run` prints the plan without executing.
678
+ `textus freshness` replaced `textus stale` in 0.9.2. Each row reports one entry's verdict (`fresh`, `stale`, `never_refreshed`, or `no_policy`) against its matched `refresh:` policy. `textus build` consumes its own staleness signal and executes derived entries' projections under the `build` role; `--dry-run` prints the plan without executing.
570
679
 
571
680
  `textus accept K --as=human` promotes a pending entry into its target zone: it copies the patch body into the target key, deletes the pending entry, and writes one audit line per side (§audit). Only the `human` role may invoke `accept`.
572
681
 
@@ -594,7 +703,7 @@ Every `Textus::Error` exposes `code`, `message`, and an optional `hint:`. The hi
594
703
  - Breaking changes (renamed/removed envelope fields, zone semantics, key grammar) require a new wire string `textus/3`.
595
704
  - Implementations MUST reject envelopes whose `protocol` they do not recognize.
596
705
 
597
- The reference Ruby gem follows semver independently. The current gem version is `0.8.0`, which speaks `textus/2`.
706
+ The reference Ruby gem follows semver independently. The current gem version is `0.9.2`, which speaks `textus/2`.
598
707
 
599
708
  ## 12. Conformance fixtures
600
709
 
@@ -604,13 +713,13 @@ A conformant implementation MUST pass these fixtures (the reference test suite s
604
713
  Given a manifest with `working.network.org` → `working/network/org` (nested), schema `person`, and a file `.textus/zones/working/network/org/jane.md` with valid frontmatter, `textus get working.network.org.jane --format=json` returns the canonical envelope with `etag` matching the file's sha256.
605
714
 
606
715
  **Fixture B — Role gate on write:**
607
- Given a manifest entry where `key: canon.identity` lives in the `canon` zone (human-only), `textus put canon.identity --stdin --as=ai` (with any valid input) returns the error envelope with `code: "write_forbidden"` and exit code 1.
716
+ Given a manifest entry where `key: identity.self` lives in the `identity` zone (human-only), `textus put identity.self --stdin --as=ai` (with any valid input) returns the error envelope with `code: "write_forbidden"` and exit code 1.
608
717
 
609
718
  **Fixture C — Schema violation:**
610
719
  Given the `person` schema and a `put` whose frontmatter omits `relationship`, the result is the error envelope with `code: "schema_violation"`, `details.missing: ["relationship"]`, and exit code 1.
611
720
 
612
721
  **Fixture D — Staleness detection:**
613
- Given a manifest entry `derived.catalogs.skills` with `generator.sources: [working.projects]`, and a working-zone entry under `working.projects` whose file mtime is newer than the derived entry's `generated.at` frontmatter timestamp, `textus stale --format=json` includes the derived entry with its declared `generator.command` and a `reason` field naming the stale source. Calling `textus stale` does NOT execute the command.
722
+ Given a manifest entry `inbox.notes` matched by a `policies: [{ match: inbox.notes, refresh: { ttl: 1h } }]` block and an envelope on disk whose `_meta.last_refreshed_at` is older than `now - ttl`, `textus freshness --format=json` includes a row for `inbox.notes` with `status: "stale"`. Calling `textus freshness` does NOT trigger a refresh.
614
723
 
615
724
  **Fixture E — Projection build:**
616
725
  Given a manifest entry `derived.catalogs.skills` whose `projection` clause selects fields from `working.projects` entries, `textus build derived.catalogs.skills` materializes the derived entry on disk with frontmatter and body matching the projected shape, and updates `generated.at` to the build timestamp.
@@ -643,6 +752,28 @@ Given a pending entry `pending.canon.identity.patch` proposing a change to `cano
643
752
 
644
753
  - **Why not vector embeddings?** Different problem. textus is for facts agents act on deterministically; embeddings are for fuzzy retrieval. They compose — index a textus tree into a vector store if you need both.
645
754
 
755
+ ## 13.1 Layered architecture (internal, 0.9.0+)
756
+
757
+ Textus internals are organized into four layers. The dependency rule is one-way — each layer may only import from the layer beneath it.
758
+
759
+ - **Interface** (`lib/textus/cli/`) — CLI verbs. Parses flags, calls a use case, formats JSON.
760
+ - **Application** (`lib/textus/application/`) — Use cases: `Reads::Get`, `Refresh::Worker`, `Refresh::Orchestrator`, `Refresh::All`. Orchestrate domain + infra; no business rules.
761
+ - **Domain** (`lib/textus/domain/`) — Pure values: `Freshness::Policy`, `Action`, `Outcome`, `Freshness::Verdict`, `Freshness::Evaluator`. No I/O, no globals, testable without disk.
762
+ - **Infrastructure** (`lib/textus/infra/`) — Adapters: `EventBus`, `Clock`, `Refresh::Lock`, `Refresh::Detached`. Wrap OS / library primitives.
763
+
764
+ The `lib/textus/store/`, `lib/textus/manifest/`, `lib/textus/hooks/` namespaces are infrastructure adapters that predate this split and remain at their existing paths for backward-compat with the plugin DSL.
765
+
766
+ Plugin authors interact only with the Hook DSL (`Textus.intake`, `Textus.refreshed`, etc.) and the manifest YAML schema. The layering is internal and may evolve.
767
+
768
+ As of 0.9.1, the write path mirrors the read path:
769
+
770
+ - **Reads** flow through `Application::Reads::Get`, which takes a `Context` and dispatches refresh via `Application::Refresh::Orchestrator`.
771
+ - **Writes** flow through `Application::Writes::{Put,Delete,Build,Accept,Publish}`, each taking a `Context`. Permission checks happen at the use-case layer (via `Context#can_write?`); I/O happens at `Store::Writer#write_envelope_to_disk` (pure).
772
+ - `Application::Context` is the universal request object: it carries `store`, `role`, `correlation_id`, `clock`, and `dry_run`. Use cases never thread these as separate kwargs.
773
+ - `Textus::Composition` is the factory module CLI verbs (and future MCP server / HTTP shim) use to construct Contexts and use cases.
774
+
775
+ See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
776
+
646
777
  ## 14. Open questions (v2.x scope)
647
778
 
648
779
  - **Locking on `put`:** the reference impl uses sha256 etags. Should the spec also define a file-lock fallback for systems where read-before-write is racy?
@@ -662,7 +793,7 @@ A `textus/2` implementation MUST:
662
793
  - [ ] Refuse writes whose resolved role is not in the target zone's `writable_by` list with `write_forbidden`.
663
794
  - [ ] Return envelopes matching the shape in §8 exactly (with `_meta`, not `frontmatter`).
664
795
  - [ ] Use the error codes in §8 and the exit-code table.
665
- - [ ] Implement `textus stale` per §5.1 and §9, comparing each derived entry's `generator.sources` against its `generated.at` timestamp without invoking any commands.
796
+ - [ ] Implement `textus freshness` per §5.1 and §9, walking each entry, matching it against the top-level `policies:` block, and reporting `fresh|stale|never_refreshed|no_policy` without invoking any refresh.
666
797
  - [ ] Pass the conformance fixtures A–I in §12.
667
798
 
668
799
  A `textus/2` implementation MAY:
data/docs/architecture.md CHANGED
@@ -31,10 +31,10 @@ CLI is the single entry point. It parses argv and dispatches each verb to whiche
31
31
 
32
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
33
 
34
- - **`Store::Reader`** — owns `get`, `list`, `where`, `uid`, `deps`, `rdeps`, `published`, `schema_envelope`, `stale`, `validate_all`. The only module that reads working-store entry files.
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
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
36
  - **`Store::Mover`** — owns `mv` (same-zone rename) with uid preservation and one audit row.
37
- - **`Store::Validator`** / **`Store::Staleness`** — back the `validate_all` / `stale` reads. Take explicit collaborators (`reader:`, `manifest:`, `audit_log:`, `schema_for:`) instead of the full store.
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
38
 
39
39
  Shared value modules and primitives consumed by Reader/Writer/Mover:
40
40
 
@@ -72,7 +72,7 @@ Builder ──► Pipeline ──► LoadSources ──► Project ──► Ren
72
72
  Declared in the manifest, loaded on demand, dispatched by `Store` and `Refresh`.
73
73
 
74
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`). 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/`.
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
76
  - **`Hooks::Builtin`** — ships built-in `:fetch` hooks (e.g. json, csv, ical-events, rss) available without user-supplied hooks.
77
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
78
 
@@ -101,7 +101,25 @@ First-class CLI verbs that don't fit the read/write/build axes. Read-mostly; sid
101
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
102
  - **`name:` frontmatter matches file basename.** Enforced on read and write.
103
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
- - **`stale` does not execute anything.** It walks `zone: derived` entries with a `generator:` block, compares `generated.at` against source mtimes, and returns offenders **plus their declared `command`**. Build runners execute. This is the §5.1 "dataflow oracle, not executor" boundary.
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.
105
123
 
106
124
  ## What this implementation deliberately leaves out
107
125
 
data/docs/conventions.md CHANGED
@@ -5,9 +5,9 @@ Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and i
5
5
  ## Key naming
6
6
 
7
7
  - **Segments are lowercase, kebab- or snake-case.** The grammar `^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$` is the hard limit. Prefer `acme-dashboard` over `acmedashboard` when there's a natural word break.
8
- - **Lead with the zone in the key path.** `state.projects.acme.dashboard`, not `projects.acme.dashboard`. The zone prefix makes it obvious from the key alone whether a write will be accepted.
9
- - **Mirror the directory structure.** If `state.projects.acme.dashboard` resolves to `state/projects/acme/dashboard.md`, do not invent shortcuts that diverge.
10
- - **Don't pluralise the leaf.** `state.network.org.jane`, not `state.network.org.janes`. Pluralise the container, not the entry.
8
+ - **Lead with the zone in the key path.** `working.projects.acme.dashboard`, not `projects.acme.dashboard`. The zone prefix makes it obvious from the key alone whether a write will be accepted.
9
+ - **Mirror the directory structure.** If `working.projects.acme.dashboard` resolves to `working/projects/acme/dashboard.md`, do not invent shortcuts that diverge.
10
+ - **Don't pluralise the leaf.** `working.network.org.jane`, not `working.network.org.janes`. Pluralise the container, not the entry.
11
11
 
12
12
  ## Zone layout
13
13
 
@@ -17,12 +17,17 @@ Recommended top-level layout — the spec allows alternatives, but this is what
17
17
  .textus/
18
18
  manifest.yaml
19
19
  schemas/ # YAML schema definitions
20
- fixed/ # identity, voice, canon — humans only
21
- state/ # agent-writable working memory
22
- derived/ # generated by build runners — never edit by hand
20
+ zones/
21
+ identity/ # identity, voice, canon — humans only
22
+ working/ # agent-writable working memory
23
+ inbox/ # script-fed external inputs
24
+ review/ # AI proposals awaiting accept
25
+ output/ # generated by build runners — never edit by hand
23
26
  ```
24
27
 
25
- Inside `state/`, group by **domain** (people, projects, decisions, runbooks), not by file type or date. Inside `derived/`, group by **producer** (`derived/catalogs/`, `derived/indexes/`) so it's clear which build job owns what.
28
+ Inside `working/`, group by **domain** (people, projects, decisions, runbooks), not by file type or date. Inside `output/`, group by **producer** (`output/catalogs/`, `output/indexes/`) so it's clear which build job owns what.
29
+
30
+ > **0.9.2 rename.** Default zone names moved off historical artifact terms (`canon/intake/pending/derived`) to one lifecycle axis (`identity/inbox/review/output`); `working` is unchanged. Upgrade existing stores by hand-editing the manifest and `mv`-ing the zone directories (see 0.9.2 CHANGELOG).
26
31
 
27
32
  ## Schema design
28
33
 
@@ -43,27 +48,29 @@ Tooling around `git blame` or audit logs may filter on owner; the gem itself onl
43
48
 
44
49
  ## Derived entries and build runners
45
50
 
46
- **Always** declare `generator:` on derived entries that participate in any build pipeline. Without it, `textus stale` cannot help — the entry is just an opaque file.
51
+ **Always** declare `generator:` on derived entries that participate in any build pipeline. Without it, `textus freshness` cannot help — the entry is just an opaque file.
47
52
 
48
53
  ```yaml
49
- - key: derived.catalogs.skills
50
- path: derived/catalogs/skills
51
- zone: derived
54
+ - key: output.catalogs.skills
55
+ path: output/catalogs/skills
56
+ zone: output
52
57
  schema: null
53
58
  owner: build:catalog-skills
54
59
  generator:
55
60
  command: "rake catalog:skills"
56
61
  sources:
57
- - state.projects
58
- - state.network
62
+ - working.projects
63
+ - working.network
59
64
  ```
60
65
 
61
66
  **The build runner is responsible for writing the `generated:` frontmatter block** when it regenerates. The gem will never synthesize it. A typical lefthook / rake / just integration looks like:
62
67
 
63
68
  ```sh
64
- textus stale --format=json | jq -r '.[] | .generator.command' | sort -u | while read cmd; do
65
- eval "$cmd"
66
- done
69
+ textus freshness --format=json \
70
+ | jq -r '.rows[] | select(.status == "stale") | .key' \
71
+ | while read key; do
72
+ textus refresh "$key" --as=script
73
+ done
67
74
  ```
68
75
 
69
76
  `generated.from` SHOULD match `generator.sources` from the manifest — they're the same list, recorded in two places so a diffable file proves what was actually consumed.
@@ -82,4 +89,4 @@ For multi-writer environments, **always pass `if_etag`** on `put`. The gem treat
82
89
 
83
90
  - **MCP servers**: a thin server that exposes `textus get` and `textus put` as tools is the recommended way to give Claude/agents access. Don't bake MCP into this gem.
84
91
  - **Vector stores**: index `body` content into a vector store if you want fuzzy retrieval. `frontmatter` stays in textus as the source of truth for deterministic facts.
85
- - **CI**: run `textus stale` (or `textus list` + schema validation) in CI to catch drift between derived entries and their sources.
92
+ - **CI**: run `textus freshness` (or `textus list` + schema validation) in CI to catch drift between derived entries and their sources.