textus 0.8.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +224 -0
- data/README.md +50 -22
- data/SPEC.md +194 -63
- data/docs/architecture.md +22 -4
- data/docs/conventions.md +24 -17
- data/lib/textus/application/context.rb +68 -0
- data/lib/textus/application/reads/audit.rb +69 -0
- data/lib/textus/application/reads/blame.rb +79 -0
- data/lib/textus/application/reads/freshness.rb +77 -0
- data/lib/textus/application/reads/get.rb +62 -0
- data/lib/textus/application/reads/policy_explain.rb +39 -0
- data/lib/textus/application/refresh/all.rb +41 -0
- data/lib/textus/application/refresh/orchestrator.rb +68 -0
- data/lib/textus/application/refresh/worker.rb +79 -0
- data/lib/textus/application/writes/accept.rb +43 -0
- data/lib/textus/application/writes/build.rb +24 -0
- data/lib/textus/application/writes/delete.rb +37 -0
- data/lib/textus/application/writes/publish.rb +25 -0
- data/lib/textus/application/writes/put.rb +44 -0
- data/lib/textus/builder.rb +27 -14
- data/lib/textus/cli/group/policy.rb +11 -0
- data/lib/textus/cli/verb/accept.rb +2 -1
- data/lib/textus/cli/verb/audit.rb +31 -0
- data/lib/textus/cli/verb/blame.rb +17 -0
- data/lib/textus/cli/verb/build.rb +2 -1
- data/lib/textus/cli/verb/delete.rb +2 -1
- data/lib/textus/cli/verb/freshness.rb +17 -0
- data/lib/textus/cli/verb/get.rb +8 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -3
- data/lib/textus/cli/verb/policy_explain.rb +15 -0
- data/lib/textus/cli/verb/policy_list.rb +25 -0
- data/lib/textus/cli/verb/put.rb +5 -4
- data/lib/textus/cli/verb/refresh.rb +2 -1
- data/lib/textus/cli/verb/refresh_stale.rb +19 -0
- data/lib/textus/cli/verb/reject.rb +15 -0
- data/lib/textus/cli.rb +16 -2
- data/lib/textus/composition.rb +71 -0
- data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
- data/lib/textus/doctor/check/intake_registration.rb +46 -0
- data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
- data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
- data/lib/textus/doctor.rb +4 -0
- data/lib/textus/domain/action.rb +9 -0
- data/lib/textus/domain/freshness/evaluator.rb +30 -0
- data/lib/textus/domain/freshness/policy.rb +18 -0
- data/lib/textus/domain/freshness/verdict.rb +12 -0
- data/lib/textus/domain/outcome.rb +10 -0
- data/lib/textus/domain/permission.rb +15 -0
- data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
- data/lib/textus/domain/policy/matcher.rb +51 -0
- data/lib/textus/domain/policy/promote.rb +24 -0
- data/lib/textus/domain/policy/refresh.rb +48 -0
- data/lib/textus/domain/policy.rb +7 -0
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/dispatcher.rb +15 -1
- data/lib/textus/hooks/dsl.rb +18 -0
- data/lib/textus/hooks/registry.rb +12 -5
- data/lib/textus/infra/clock.rb +9 -0
- data/lib/textus/infra/event_bus.rb +27 -0
- data/lib/textus/infra/publisher.rb +73 -0
- data/lib/textus/infra/refresh/detached.rb +38 -0
- data/lib/textus/infra/refresh/lock.rb +44 -0
- data/lib/textus/init.rb +71 -28
- data/lib/textus/intro.rb +19 -11
- data/lib/textus/manifest/entry.rb +18 -9
- data/lib/textus/manifest/policies.rb +83 -0
- data/lib/textus/manifest.rb +30 -0
- data/lib/textus/proposal.rb +4 -21
- data/lib/textus/publisher.rb +4 -69
- data/lib/textus/refresh.rb +9 -44
- data/lib/textus/store/mover.rb +14 -9
- data/lib/textus/store/reader.rb +10 -8
- data/lib/textus/store/staleness.rb +4 -16
- data/lib/textus/store/validator.rb +46 -20
- data/lib/textus/store/view.rb +8 -19
- data/lib/textus/store/writer.rb +51 -14
- data/lib/textus/store.rb +29 -9
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +1 -0
- metadata +46 -2
- 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-
|
|
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
|
-
|
|
65
|
+
identity/ # zone: identity (human-only)
|
|
66
66
|
working/ # zone: working (human, ai, script)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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:
|
|
97
|
+
- name: identity
|
|
98
98
|
writable_by: [human]
|
|
99
99
|
- name: working
|
|
100
100
|
writable_by: [human, ai, script]
|
|
101
|
-
- name:
|
|
101
|
+
- name: inbox
|
|
102
102
|
writable_by: [script]
|
|
103
|
-
- name:
|
|
104
|
-
writable_by: [ai]
|
|
105
|
-
- name:
|
|
103
|
+
- name: review
|
|
104
|
+
writable_by: [ai, human]
|
|
105
|
+
- name: output
|
|
106
106
|
writable_by: [build]
|
|
107
107
|
|
|
108
108
|
entries:
|
|
109
|
-
- key:
|
|
110
|
-
path:
|
|
111
|
-
zone:
|
|
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:
|
|
122
|
-
path:
|
|
123
|
-
zone:
|
|
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
|
-
| `
|
|
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
|
-
| `
|
|
190
|
-
| `
|
|
191
|
-
| `
|
|
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
|
|
259
|
+
### 5.4 Intake (declared, refreshed via registered intake handler)
|
|
254
260
|
|
|
255
|
-
Intake entries declare an external source by naming
|
|
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:
|
|
259
|
-
zone:
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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 `:
|
|
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 hook verb: `Textus.hook(event, name, **opts) { ... }`. The EVENTS table below defines every extension point. Files in `.textus/hooks
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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 (`
|
|
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]
|
|
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
|
-
| `
|
|
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
|
|
660
|
+
**`textus freshness` output shape:**
|
|
558
661
|
|
|
559
662
|
```json
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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 `
|
|
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
|
|
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`, `
|
|
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` / `
|
|
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
|
-
- **`
|
|
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.** `
|
|
9
|
-
- **Mirror the directory structure.** If `
|
|
10
|
-
- **Don't pluralise the leaf.** `
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 `
|
|
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
|
|
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:
|
|
50
|
-
path:
|
|
51
|
-
zone:
|
|
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
|
-
-
|
|
58
|
-
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
92
|
+
- **CI**: run `textus freshness` (or `textus list` + schema validation) in CI to catch drift between derived entries and their sources.
|