textus 0.47.1 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/README.md +9 -7
- data/SPEC.md +44 -69
- data/docs/reference/conventions.md +13 -12
- data/lib/textus/boot.rb +47 -32
- data/lib/textus/builder/renderer/json.rb +1 -1
- data/lib/textus/cli/runner.rb +5 -4
- data/lib/textus/cli/verb/boot.rb +2 -1
- data/lib/textus/cli.rb +0 -1
- data/lib/textus/dispatcher.rb +3 -8
- data/lib/textus/doctor/check/generator_drift.rb +28 -0
- data/lib/textus/doctor/check/lifecycle_action_invalid.rb +39 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +1 -1
- data/lib/textus/doctor.rb +2 -0
- data/lib/textus/domain/lifecycle.rb +83 -0
- data/lib/textus/domain/policy/base_guards.rb +2 -2
- data/lib/textus/domain/policy/lifecycle.rb +35 -0
- data/lib/textus/domain/staleness.rb +6 -3
- data/lib/textus/envelope/io/writer.rb +2 -2
- data/lib/textus/hooks/context.rb +1 -1
- data/lib/textus/init.rb +4 -4
- data/lib/textus/maintenance/key_delete_prefix.rb +1 -1
- data/lib/textus/maintenance/key_mv_prefix.rb +2 -2
- data/lib/textus/maintenance/tend.rb +110 -0
- data/lib/textus/manifest/entry/base.rb +1 -0
- data/lib/textus/manifest/entry/derived.rb +4 -2
- data/lib/textus/manifest/rules.rb +11 -23
- data/lib/textus/manifest/schema.rb +4 -19
- data/lib/textus/mcp/server.rb +9 -2
- data/lib/textus/ports/audit_log.rb +1 -1
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/fetch/detached.rb +5 -1
- data/lib/textus/read/boot.rb +4 -2
- data/lib/textus/read/freshness.rb +37 -26
- data/lib/textus/read/get.rb +47 -32
- data/lib/textus/read/pulse.rb +1 -1
- data/lib/textus/read/rule_explain.rb +10 -16
- data/lib/textus/read/rule_list.rb +5 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +8 -12
- data/lib/textus/write/{delete.rb → key_delete.rb} +3 -3
- data/lib/textus/write/{mv.rb → key_mv.rb} +4 -4
- data/lib/textus/write/reject.rb +1 -1
- metadata +8 -15
- data/lib/textus/cli/group/fetch.rb +0 -20
- data/lib/textus/cli/verb/fetch.rb +0 -14
- data/lib/textus/cli/verb/fetch_all.rb +0 -20
- data/lib/textus/domain/policy/fetch.rb +0 -37
- data/lib/textus/domain/policy/retention.rb +0 -26
- data/lib/textus/domain/retention.rb +0 -44
- data/lib/textus/domain/staleness/intake_check.rb +0 -54
- data/lib/textus/maintenance/migrate.rb +0 -65
- data/lib/textus/read/retainable.rb +0 -17
- data/lib/textus/read/stale.rb +0 -17
- data/lib/textus/write/fetch_all.rb +0 -53
- data/lib/textus/write/retention_sweep.rb +0 -64
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 721213d52e7efc2f5bd46abf386bd099ac1d4bd3cec33d956d8a3c43b4d76018
|
|
4
|
+
data.tar.gz: 25695c008eb417926488b6881ad44f5867c7f56b72b721bd8b7b2d21a34eeb9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b6cbf56a3c352f9715dffafd74c0c39459766181efb97624e4aabed8cf3a32f744ea6f5fa7d620a5a61ad7e9050543bfb86d20e2d5b272e10578a5018a7ddc76
|
|
7
|
+
data.tar.gz: 95be6f8590727b6cc64aa52b84d456dac78c035f279af6819184a698ee75400b6a54c336008b47f93985efee42e4d9bac1909d40d5c53edfd16843f2de367cab
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,57 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
|
|
|
9
9
|
bump is a breaking change that requires a store migration; the gem version
|
|
10
10
|
tracks both additive improvements and breaking protocol bumps independently.
|
|
11
11
|
|
|
12
|
+
## 0.50.0 — 2026-06-04 — Observability on two axes + boot lifecycle (ADR 0083, 0084, 0085)
|
|
13
|
+
|
|
14
|
+
`freshness` collapses into `pulse`, the contract-drift guard stops deadlocking, and `boot` gains a lean session-start projection shipped via a plugin.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`boot --lean`** (ADR 0084) — a compact orientation projection (`protocol`, `store_root`, `zones`, `agent_quickstart`, `contract_etag`) for cheap session-start injection. The full `boot` envelope now also carries `contract_etag`. `--lean` is a contract arg, so it threads to every surface (CLI flag, MCP `lean` tool arg, Ruby kwarg).
|
|
19
|
+
- **textus ships as a Claude Code plugin** (ADR 0084) — a single self-contained `.claude-plugin/plugin.json` with an **inline** `SessionStart` hook (`startup`/`clear`/`compact`) that runs `boot --lean`, so enabling the plugin auto-orients each session. The agent-invokable `boot` tool is unchanged; the hook is additive. Plugin data is kept inside `.claude-plugin/` (a separate concern from the gem), not a top-level `hooks/` dir.
|
|
20
|
+
- **Agent-integration config is textus-managed, projected from canon** (ADR 0086). This repo's `.mcp.json` and `.claude-plugin/plugin.json` are now **build artifacts** — `textus build` projects them from `knowledge.project` + `Textus::VERSION` (so the plugin version can never drift off the gem) the same way it projects `CLAUDE.md`. The plugin manifest also gains an inline `mcpServers` stanza (enable the plugin → MCP server *and* orientation hook in one). A new `provenance: false` derived-entry flag lets the JSON renderer emit config without a `_meta` block. Downstream consumers are unaffected — `init --with-agent` still scaffolds a write-once `.mcp.json` (a build must never clobber a user's config).
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **The contract-drift guard applies to mutating verbs only; `boot` self-heals** (ADR 0083). A mid-session manifest/hook/schema edit no longer deadlocks every MCP verb behind "re-run boot": pure reads and `boot` bypass the guard, `boot` re-arms the session's cached `contract_etag`, and only mutating verbs (the `Write::` family + the destructive `Maintenance::` verbs `tend`/`zone_mv`/`key_mv_prefix`/`key_delete_prefix`) enforce it. "Re-run boot" now actually recovers instead of looping. Refines ADR 0074.
|
|
25
|
+
- **`tend` returns a health summary, not full `issues[]`** (ADR 0085) — matching `pulse`; `doctor` stays the sole owner of detailed health output.
|
|
26
|
+
|
|
27
|
+
### Removed (breaking)
|
|
28
|
+
|
|
29
|
+
- **The public `freshness` verb** (ADR 0085). Observability is now two verbs on orthogonal axes — `pulse` (transient/temporal: `changed` + `stale` + `next_due_at`) and `doctor` (structural correctness). The lifecycle scan that backed `freshness` becomes a Ruby-only internal (empty `surfaces`, ADR 0073) consumed by `pulse` and the hook context; per-entry detail is reconstructable from `get` (carries `stale`/`last_fetched_at`) + `rule_explain` (the `lifecycle:` ttl + `on_expire`). **Migration:** replace `textus freshness` with `textus pulse` (for the `stale` list / `next_due_at`) or `get` + `rule_explain` (for one entry's verdict). SPEC's generator-drift attribution is corrected to `doctor`'s `generator_drift` check.
|
|
30
|
+
|
|
31
|
+
## 0.49.0 — 2026-06-04 — Normalize the key-verb family + remove `migrate` (ADR 0082)
|
|
32
|
+
|
|
33
|
+
The single-key mutation verbs gain the `key_` family stem, and the `migrate` orchestrator is removed.
|
|
34
|
+
|
|
35
|
+
### Changed (breaking)
|
|
36
|
+
|
|
37
|
+
- **`mv` → `key_mv` and `delete` → `key_delete`**, renamed everywhere the token is load-bearing: the verb / MCP-tool id, the guard transition symbol, the manifest `guard:` transition key, and the audit-log verb string. The single-key verbs now share the `key_` stem with their bulk cousins `key_mv_prefix`/`key_delete_prefix`; `zone_mv` is unchanged. **The CLI is unchanged** — `key mv`, `key delete`, `key mv-prefix`, `key delete-prefix` keep their spelling. **Migration:** manifests with `guard: { mv: … }` / `guard: { delete: … }` blocks must rename those keys to `key_mv` / `key_delete`; MCP clients pinned to the `mv`/`delete` tool ids must update; audit rows written before this release keep their `mv`/`delete` verb strings (readers must accept both).
|
|
38
|
+
|
|
39
|
+
### Removed (breaking)
|
|
40
|
+
|
|
41
|
+
- **The `migrate` verb (YAML-plan orchestrator).** It was non-transactional and added a second input format for no capability the primitives lack — its `zone_mv`/`key_mv_prefix`/`key_delete_prefix` ops remain individually callable, each with `--dry-run`. Removed from the CLI and the MCP catalog.
|
|
42
|
+
|
|
43
|
+
## 0.48.0 — 2026-06-04 — Unified `lifecycle` policy + docs become canon (ADR 0079, 0081)
|
|
44
|
+
|
|
45
|
+
Staleness and retention collapse into one age policy, and the upkeep verb surface shrinks 8→3.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- **`docs/` is now textus canon (ADR 0081).** Every committed doc is authored under `.textus/zones/knowledge/` and published back to `docs/` by `textus build` (the same projection mechanism as `CLAUDE.md`/`AGENTS.md`); `docs/` is now a committed, sentinel-managed mirror. Adopt-in-place (ADR 0050) made the migration a zero-diff republish. `docs/assets/` (branding binaries) moved to repo-root `assets/`; `docs/plans/` (gitignored) is unchanged.
|
|
50
|
+
- **One `lifecycle: { ttl, on_expire }` rule slot** replaces the separate `fetch:` (intake freshness) and `retention:` (leaf pruning) slots. `on_expire` is `refresh` (re-pull intake), `warn` (flag on read), `drop` (delete), or `archive` (copy aside then delete). An action's *destructiveness* decides where it runs: non-destructive `refresh`/`warn` are applied lazily on `get`; destructive `drop`/`archive` run only on the `tend` sweep. A read never deletes.
|
|
51
|
+
- **`tend` is now the destructive-only sweep** — it drops/archives expired entries and refreshes cold intake, superseding the composite body of ADR 0078. Result keys are `dropped`/`archived`/`refreshed`/`failed` (apply) and `would_drop`/`would_archive`/`would_refresh` (`--dry-run`).
|
|
52
|
+
- **`freshness` reports the unified verdict** (`fresh`/`expired`/`no_policy`) and the matched `on_expire` action; `pulse`'s `stale` list now reflects expired entries.
|
|
53
|
+
|
|
54
|
+
### Removed
|
|
55
|
+
|
|
56
|
+
- **The `stale`, `retainable`, `fetch`, `fetch_all`, and `retain` verbs.** Read-through refresh is `get` (lazy); destructive pruning is `tend`; `FetchWorker` remains as the internal executor for `get`/`tend`. Generator/build drift (dependency-based, not age-based) is now the `textus doctor` `generator_drift` check.
|
|
57
|
+
- **The `fetch:`/`retention:` rule slots and the per-rule `fetch_timeout_seconds` override** (accepted loss; the constant ceiling applies to every intake).
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- **`doctor` checks `lifecycle.action_invalid`** (`refresh` only on intake; `drop`/`archive` only on stored entries) and **`generator_drift`** (the surviving home for build-drift detection).
|
|
62
|
+
|
|
12
63
|
## 0.47.1 — 2026-06-04 — External entries are a non-build path
|
|
13
64
|
|
|
14
65
|
### Fixed
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<picture>
|
|
3
|
-
<source media="(prefers-color-scheme: dark)" srcset="
|
|
4
|
-
<img src="
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/branding/wordmark-dark.png">
|
|
4
|
+
<img src="assets/branding/wordmark.png" alt="textus" width="360">
|
|
5
5
|
</picture>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
@@ -184,7 +184,7 @@ textus get knowledge.notes.org.jane
|
|
|
184
184
|
textus list --zone=knowledge
|
|
185
185
|
printf '%s' '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
|
|
186
186
|
| textus put knowledge.notes.bob --as=human --stdin
|
|
187
|
-
textus freshness --zone=artifacts # per-entry fresh/
|
|
187
|
+
textus freshness --zone=artifacts # per-entry fresh/expired/no_policy + on_expire action
|
|
188
188
|
textus rule list # show every rule block
|
|
189
189
|
textus audit --limit=20 # query the audit log
|
|
190
190
|
```
|
|
@@ -263,12 +263,14 @@ Textus.hook do |reg|
|
|
|
263
263
|
end
|
|
264
264
|
```
|
|
265
265
|
|
|
266
|
-
|
|
266
|
+
Stale intake entries refresh lazily on read: a `textus get KEY` whose matched
|
|
267
|
+
`lifecycle` rule says `on_expire: refresh` re-pulls the source in-process before
|
|
268
|
+
returning. To find what is due, list the expired entries:
|
|
267
269
|
|
|
268
270
|
```sh
|
|
269
|
-
textus
|
|
270
|
-
#
|
|
271
|
-
textus
|
|
271
|
+
textus freshness --zone=feeds --output=json # rows with status: "expired"
|
|
272
|
+
# then read each one (read-through refresh per its lifecycle rule):
|
|
273
|
+
textus get feeds.calendar.events --as=automation
|
|
272
274
|
```
|
|
273
275
|
|
|
274
276
|
See SPEC.md §5.10 for the full hook contract.
|
data/SPEC.md
CHANGED
|
@@ -148,7 +148,7 @@ Textus internals (`manifest.yaml`, `schemas/`, `templates/`, `hooks/`) live dire
|
|
|
148
148
|
|
|
149
149
|
Zone directories under `zones/` are conventional; their write semantics are derived from the zone's declared `kind:` (and the capabilities roles hold), not the directory name.
|
|
150
150
|
|
|
151
|
-
`.textus/audit.log` is an append-only NDJSON file written under a file lock by every successful `put`, `
|
|
151
|
+
`.textus/audit.log` is an append-only NDJSON file written under a file lock by every successful `put`, `key_delete`, `accept`, and `build`. `.textus/role` (one line containing a role name) is optional and participates in the role-resolution order (§5).
|
|
152
152
|
|
|
153
153
|
### 3.1 Store location precedence
|
|
154
154
|
|
|
@@ -208,7 +208,7 @@ entries:
|
|
|
208
208
|
|
|
209
209
|
rules:
|
|
210
210
|
- match: feeds.**
|
|
211
|
-
|
|
211
|
+
lifecycle: { ttl: 6h, on_expire: warn }
|
|
212
212
|
|
|
213
213
|
audit:
|
|
214
214
|
max_size: 10485760 # bytes before rotating (default: 10 485 760 = 10 MiB)
|
|
@@ -398,7 +398,7 @@ No partials. No lambdas. No HTML escaping (output is raw text, intended for Mark
|
|
|
398
398
|
|
|
399
399
|
#### 5.2.2 External compute (`kind: external`)
|
|
400
400
|
|
|
401
|
-
A derived entry that is produced by a build tool *outside* textus — `rake`, `just`, a shell script, anything — declares `compute: { kind: external, ... }`. textus does **not** execute the command (consistent with §2); the external automation is responsible for writing the file. textus records `sources:` so `
|
|
401
|
+
A derived entry that is produced by a build tool *outside* textus — `rake`, `just`, a shell script, anything — declares `compute: { kind: external, ... }`. textus does **not** execute the command (consistent with §2); the external automation is responsible for writing the file. textus records `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the derived file's `_meta.generated.at` and report staleness. (Generator/build drift is dependency-based, not age-based; ADR 0079 keeps it out of the `lifecycle:` unification and ADR 0085 keeps it out of the internal `freshness` scan — it is a `doctor` health check.)
|
|
402
402
|
|
|
403
403
|
```yaml
|
|
404
404
|
- key: output.catalogs.skills
|
|
@@ -415,9 +415,9 @@ A derived entry that is produced by a build tool *outside* textus — `rake`, `j
|
|
|
415
415
|
|
|
416
416
|
**`sources:`** is a list. Each element is either a dotted key prefix (matched against manifest entries) or a filesystem path (relative to the repo root, or absolute). For each key prefix, every matching entry's file mtime is checked. For each path, file or directory mtime is checked.
|
|
417
417
|
|
|
418
|
-
**`command:`** is recorded in the staleness row's `generator` field but never executed. It exists so `
|
|
418
|
+
**`command:`** is recorded in the staleness row's `generator` field but never executed. It exists so `doctor`'s `generator_drift` output can carry a hint about how to regenerate.
|
|
419
419
|
|
|
420
|
-
**
|
|
420
|
+
**Generator-drift contract.** An entry with `compute: { kind: external }` is reported by `doctor`'s `generator_drift` check as drifted when:
|
|
421
421
|
- The derived file does not exist, OR
|
|
422
422
|
- `_meta.generated.at` is missing or unparseable, OR
|
|
423
423
|
- Any `sources:` element has been modified after `_meta.generated.at`.
|
|
@@ -454,9 +454,9 @@ A sentinel is written for each published file at `<store_root>/.run/sentinels/<t
|
|
|
454
454
|
|
|
455
455
|
**Subtree mirror.** A nested entry MAY declare `publish: { tree: "dir" }` instead of `to:` (see §4). On every build, textus walks the entry's full stored subtree (`zones/<path>/**`), applies the entry's `ignore:` filter, and byte-copies each file to the target directory, preserving relative layout — one sentinel per file under `<store_root>/.run/sentinels/`. The mirror is path-driven: no keys are enumerated, no template variables are interpreted, and mirrored files are opaque payload (never addressable). On rebuild, the entire target directory is pruned of textus-managed files the current source no longer produces; unmanaged files are never touched. The build envelope grows a `published_leaves` array — one row per mirrored file, with `key`, `source`, and `target` — alongside the existing `built` array, plus a `pruned` array listing any orphaned managed files removed on this build. Targets that would resolve outside the repo root are refused. When a `publish.tree` target overlaps a `derived` entry's `publish.to` (e.g. a derived `SKILL.md` written into the mirrored dir), the mirroring entry must `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap` (ADR 0047).
|
|
456
456
|
|
|
457
|
-
### 5.4 Intake (declared,
|
|
457
|
+
### 5.4 Intake (declared, refreshed via registered intake handler)
|
|
458
458
|
|
|
459
|
-
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
|
|
459
|
+
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 a read-through `textus get KEY` encounters a stale entry whose `lifecycle` rule says `on_expire: refresh`. The declaration is data only:
|
|
460
460
|
|
|
461
461
|
```yaml
|
|
462
462
|
- key: feeds.calendar.events
|
|
@@ -468,23 +468,22 @@ Intake entries declare an external source by naming an **intake handler** — a
|
|
|
468
468
|
|
|
469
469
|
rules:
|
|
470
470
|
- match: feeds.calendar.**
|
|
471
|
-
|
|
471
|
+
lifecycle:
|
|
472
472
|
ttl: 6h
|
|
473
|
-
|
|
474
|
-
|
|
473
|
+
on_expire: refresh # refresh | warn | drop | archive
|
|
474
|
+
budget_ms: 500 # bound the in-process refresh (default: 500)
|
|
475
475
|
```
|
|
476
476
|
|
|
477
|
-
`handler` names a registered `:resolve_intake` hook (see §5.10 for the hook contract); `config` is an opaque hash handed to the handler. The freshness budget (`ttl`, `
|
|
477
|
+
`handler` names a registered `:resolve_intake` hook (see §5.10 for the hook contract); `config` is an opaque hash handed to the handler. The freshness budget (`ttl`, `on_expire`, `budget_ms`) lives in a top-level **`rules:`** block matched by key glob (§5.11).
|
|
478
478
|
|
|
479
|
-
#### `
|
|
479
|
+
#### `on_expire:` semantics
|
|
480
480
|
|
|
481
|
-
`
|
|
481
|
+
`on_expire:` declares what happens when `get` encounters an expired (past-TTL) intake entry. `get` is **read-through on every surface** (CLI, Ruby, MCP): it returns the freshest obtainable envelope, refreshing on an expired verdict per the entry's `lifecycle` rule and degrading to a pure on-disk read for keys with no lifecycle rule (ADR 0062). The value lives on the matching policy block, not on the entry. For intake entries the only valid actions are `refresh` and `warn` (`drop`/`archive` apply to stored entries and are enforced by `doctor` via `lifecycle.action_invalid`).
|
|
482
482
|
|
|
483
483
|
| Value | Behaviour |
|
|
484
484
|
|---|---|
|
|
485
485
|
| `warn` (default) | Return the entry immediately with `stale: true`, `stale_reason:` populated, and `fetching: false`. No blocking. |
|
|
486
|
-
| `
|
|
487
|
-
| `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`, `fetching: true`) and let the fetch complete in the background. Fires `:fetch_backgrounded` when the deadline is exceeded. |
|
|
486
|
+
| `refresh` | Block the `get` call, run the intake handler in-process under a `budget_ms` deadline (default 500 ms), write the result, and return the fresh envelope. If the handler does not finish in time, return the stale envelope (with `stale: true`, `fetching: true`) and let the refresh complete in the background. Fires `:fetch_backgrounded` when the deadline is exceeded. |
|
|
488
487
|
|
|
489
488
|
> **Note:** `list`/`where` paths do **not** annotate freshness — only `get` does.
|
|
490
489
|
|
|
@@ -496,10 +495,10 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
|
|
|
496
495
|
|
|
497
496
|
**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.
|
|
498
497
|
|
|
499
|
-
**
|
|
498
|
+
**Refresh paths.** Two are supported:
|
|
500
499
|
|
|
501
|
-
1. **In-process** — `textus
|
|
502
|
-
2. **External automation** — a cron job or agent harness reads `
|
|
500
|
+
1. **In-process** — a read-through `textus get KEY --as=automation` on a stale entry whose rule says `on_expire: refresh` resolves the entry's `intake.handler`, invokes the registered `:resolve_intake` hook with `(caps:, config:, args: {})`, and writes the result under a role holding `fetch` (`automation` by default).
|
|
501
|
+
2. **External automation** — a cron job or agent harness reads the `stale` list from `textus pulse` (the soonest deadline is `next_due_at`), fetches the sources of the keys reported stale out of band, and pipes bytes back through `textus put KEY --as=automation --stdin`. (`pulse` derives `stale`/`next_due_at` from the internal lifecycle scan; ADR 0085 removed the standalone `freshness` verb. For per-entry detail — ttl, age, on_expire action — read `textus get KEY` and `textus rule_explain KEY`.)
|
|
503
502
|
|
|
504
503
|
Both paths share the same write gate, audit-log entry, and `:entry_fetched` event. User-supplied hooks live in `.textus/hooks/**/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
|
|
505
504
|
|
|
@@ -536,9 +535,9 @@ Schema (one JSON object per line, no interior whitespace):
|
|
|
536
535
|
|
|
537
536
|
`seq` is a monotonic integer counter, auto-incremented on each append. It is the foundation for cursor-based queries: `textus audit --seq-since=N` returns only rows with `seq > N`, and `textus pulse --since=N` builds its `changed` array from the same cursor. When an agent's cursor falls below the oldest available seq (due to log rotation), the operation raises `CursorExpired`.
|
|
538
537
|
|
|
539
|
-
`ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `
|
|
538
|
+
`ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `key_delete`, `accept`, `compute`, `key_mv`, ...; rows written before ADR 0082 used `delete`/`mv` — readers must accept both). `key` is the affected entry key. `etag_before` and `etag_after` are the entry etags before and after the write, or JSON `null` when not applicable (e.g. create has no before-etag, delete has no after-etag).
|
|
540
539
|
|
|
541
|
-
For `
|
|
540
|
+
For `key_mv`, the structural fields `from_key`, `to_key`, and `uid` appear at the top level of the JSON object. Remaining verb-specific data (e.g. `from_path`, `to_path`) is nested under an `extras` key. The `extras` key is omitted entirely when empty.
|
|
542
541
|
|
|
543
542
|
**Rotation.** After every successful append the implementation checks whether `audit.log` exceeds `max_size` bytes (checked inside the held `flock`, so the check sees the post-write size). If it does, the active log is rotated:
|
|
544
543
|
|
|
@@ -669,7 +668,7 @@ The three `:fetch_*` lifecycle events report the progress and failures of backgr
|
|
|
669
668
|
**Signature invariant** — hooks receive a capability handle as their first keyword argument; the name depends on the mode:
|
|
670
669
|
|
|
671
670
|
- **RPC hooks** (`rpc` mode) receive `caps:` — a `Textus::Container`. Event-specific kwargs (`config:`, `args:`, `rows:`) follow in the stable order shown in the table above.
|
|
672
|
-
- **Pub-sub hooks** (`pubsub` mode) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface: `get`, `list`, `deps`, `freshness` (reads), `put`, `delete`, `audit` (authorized writes), `publish_followup`, plus `role` and `correlation_id`. The raw `Store` is not handed out.
|
|
671
|
+
- **Pub-sub hooks** (`pubsub` mode) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface: `get`, `list`, `deps`, `freshness` (reads — `freshness` is the Ruby-only internal lifecycle scan, ADR 0085, not a public verb), `put`, `delete`, `audit` (authorized writes), `publish_followup`, plus `role` and `correlation_id`. The raw `Store` is not handed out.
|
|
673
672
|
|
|
674
673
|
Declaring `store:` instead of `caps:` in an RPC callable will pass registration but raise `UsageError` at call time (`Hooks::RpcRegistry#invoke` rejects `store:` — there is no shim).
|
|
675
674
|
|
|
@@ -688,10 +687,10 @@ A manifest MAY declare a top-level `rules:` block — a list of rule blocks matc
|
|
|
688
687
|
```yaml
|
|
689
688
|
rules:
|
|
690
689
|
- match: feeds.**
|
|
691
|
-
|
|
690
|
+
lifecycle: { ttl: 6h, on_expire: warn }
|
|
692
691
|
|
|
693
692
|
- match: feeds.calendar.**
|
|
694
|
-
|
|
693
|
+
lifecycle: { ttl: 30m, on_expire: refresh, budget_ms: 800 }
|
|
695
694
|
intake_handler_allowlist: [ical-events]
|
|
696
695
|
|
|
697
696
|
- match: proposals.**
|
|
@@ -703,24 +702,21 @@ rules:
|
|
|
703
702
|
|
|
704
703
|
| Slot | Type | Meaning |
|
|
705
704
|
|---|---|---|
|
|
706
|
-
| `
|
|
705
|
+
| `lifecycle` | `{ ttl, on_expire, budget_ms? }` | Unified age policy (ADR 0079). `on_expire` is `refresh` (re-pull intake), `warn` (flag on read), `drop` (delete), or `archive` (copy to `<store>/archive/<relative-path>` then delete). Non-destructive actions (`refresh`/`warn`) are applied lazily on `get`; destructive actions (`drop`/`archive`) only on the `tend` sweep. `refresh` is valid only for intake entries; `drop`/`archive` only for stored entries (`doctor` `lifecycle.action_invalid` enforces). Age is measured from `_meta.last_fetched_at` (intake) when present, else the leaf file's modification time. `budget_ms` (optional) bounds a `refresh` to a deadline, returning the stale envelope and refreshing in the background when exceeded. |
|
|
707
706
|
| `intake_handler_allowlist` | list of strings | Constrains which `intake.handler:` names may be used by entries matched by this block. Enforced by `textus doctor`. |
|
|
708
|
-
| `guard` | `{ <transition>: [predicates] }` | Extra predicates composed (AND) onto a write transition's built-in **base** guard (ADR 0031). Keyed by transition (`put`, `
|
|
709
|
-
| `retention` | `{ expire_after:, archive_after: }` | Pruning policy for matched leaves. Duration strings: `30s`, `90m`, `12h`, `30d`, or bare integer seconds. `textus retain --as=ROLE` sweeps matched leaves: `expire_after` is checked first, so a leaf older than `expire_after` is deleted (and audited); otherwise a leaf older than `archive_after` is copied to `<store>/archive/<relative-path>` and then deleted. Age is measured from the leaf file's modification time. The `--as` role must be allowed to write the matched zone. |
|
|
707
|
+
| `guard` | `{ <transition>: [predicates] }` | Extra predicates composed (AND) onto a write transition's built-in **base** guard (ADR 0031). Keyed by transition (`put`, `key_delete`, `key_mv`, `accept`, `reject`, `fetch`). Predicate names are drawn from the closed vocabulary (`zone_writable_by`, `schema_valid`, `author_held`, `target_is_canon`, `etag_match`, `fresh_within`); parameterized predicates use `{ name: param }` form, e.g. `{ fresh_within: "1h" }`. Enforced — the transition refuses (`guard_failed`) if any predicate fails; the topology refusal keeps the `write_forbidden` code. |
|
|
710
708
|
|
|
711
|
-
|
|
712
|
-
`
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
`
|
|
716
|
-
any leaf whose zone the `--as` role cannot write is reported as a failure rather
|
|
717
|
-
than aborting the run.
|
|
709
|
+
The `lifecycle:` slot unifies the former `fetch:` (intake freshness) and
|
|
710
|
+
`retention:` (leaf pruning) slots into one age policy (ADR 0079). Generator/build
|
|
711
|
+
drift — a derived entry whose sources changed since its `generated.at` — is
|
|
712
|
+
dependency-based, not age-based, and is reported by the `textus doctor`
|
|
713
|
+
`generator_drift` check rather than this slot.
|
|
718
714
|
|
|
719
715
|
**Match grammar.** `match:` is a single glob using `*` (single segment) and `**` (any depth). A literal segment ranks more specifically than `*`; `*` ranks more specifically than `**`.
|
|
720
716
|
|
|
721
|
-
**Resolution.** For each key textus computes a `RuleSet {
|
|
717
|
+
**Resolution.** For each key textus computes a `RuleSet { handler_allowlist, guard, lifecycle }` 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` (`rule_ambiguity`).
|
|
722
718
|
|
|
723
|
-
**Read surface.** `textus rule list` dumps every block. `textus rule explain KEY` shows the resolved `RuleSet` for one key — lean effective `{
|
|
719
|
+
**Read surface.** `textus rule list` dumps every block. `textus rule explain KEY` shows the resolved `RuleSet` for one key — lean effective `{lifecycle, guard}` by default; `--detail` adds every matched block and the effective guard predicate names for every write transition (ADR 0059).
|
|
724
720
|
|
|
725
721
|
### 5.12 Storage formats
|
|
726
722
|
|
|
@@ -826,9 +822,9 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
|
|
|
826
822
|
- `etag` MUST be `sha256:<hex>` of the raw file bytes, computed identically for every format.
|
|
827
823
|
- `schema_ref` MAY be `null` for entries in subtrees with `schema: null`.
|
|
828
824
|
- `uid` is the stable Textus UID (§7) if the entry carries one, else `null`. Always present in the envelope.
|
|
829
|
-
- `stale` is `true` when the entry's TTL has elapsed and the data has not yet been
|
|
825
|
+
- `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 `lifecycle:` rule slot (typically `feeds` / quarantine zone); always `false` elsewhere.
|
|
830
826
|
- `stale_reason` is a short human-readable string describing why the entry is stale (e.g. `"ttl_exceeded"`, `"never_fetched"`), or `null` when `stale` is `false`.
|
|
831
|
-
- `fetching` is `true` when
|
|
827
|
+
- `fetching` is `true` when an `on_expire: refresh` background refresh is in flight for this entry; `false` otherwise. Callers observing `stale: true, fetching: true` SHOULD retry after a short delay.
|
|
832
828
|
|
|
833
829
|
> **Note:** `list`/`where` envelopes do **not** include `stale`, `stale_reason`, or `fetching` — freshness annotation is only provided by `get`.
|
|
834
830
|
|
|
@@ -867,9 +863,8 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
867
863
|
|---|---|---|
|
|
868
864
|
| `list [--prefix=K] [--zone=Z]` | read | any |
|
|
869
865
|
| `where K` | read | any |
|
|
870
|
-
| `get K [--no-fetch]` | read (read-through by default:
|
|
866
|
+
| `get K [--no-fetch]` | read (read-through by default: refresh-on-stale per the entry's `lifecycle` rule when `on_expire: refresh`, degrades to a pure read; `--no-fetch` / `{fetch:false}` for an explicit pure on-disk read) | any |
|
|
871
867
|
| `schema show K` | read | any |
|
|
872
|
-
| `freshness [--prefix=K] [--zone=Z]` | read | any |
|
|
873
868
|
| `audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]` | read | any |
|
|
874
869
|
| `blame KEY` | read | any |
|
|
875
870
|
| `rule list` / `rule explain KEY` | read | any |
|
|
@@ -883,10 +878,8 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
883
878
|
| `put K --stdin --as=R [--fetch=NAME]` | write | per zone |
|
|
884
879
|
| `propose K --stdin --as=R` | write | `propose`-holder (auto-prefixes propose_zone) |
|
|
885
880
|
| `key delete K --if-etag=E --as=R` | write | per zone |
|
|
886
|
-
| `fetch KEY --as=automation` | write | `fetch`-holder (typically `automation`) |
|
|
887
|
-
| `fetch all [--prefix=K] [--zone=Z] [--as=automation]` | write | `fetch`-holder (typically `automation`) |
|
|
888
881
|
| `build [--prefix=K] [--dry-run]` | write | `build`-holder (typically `automation`) |
|
|
889
|
-
| `
|
|
882
|
+
| `tend [--prefix=K] [--zone=Z] [--dry-run] --as=ROLE` | write | per zone (role must write the matched zone) |
|
|
890
883
|
| `accept K --as=human` | write | `author`-holder (typically `human`) |
|
|
891
884
|
| `reject K --as=human` | write | `author`-holder (typically `human`) |
|
|
892
885
|
| `init` | write | `human` |
|
|
@@ -900,7 +893,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
900
893
|
{
|
|
901
894
|
"agent_quickstart": {
|
|
902
895
|
"read_verbs": ["get", "list", "pulse", "schema_show", "boot", "rule_explain", "where", "deps", "rdeps"],
|
|
903
|
-
"write_verbs": ["accept", "
|
|
896
|
+
"write_verbs": ["accept", "key_delete", "key_mv", "propose", "put", "reject"],
|
|
904
897
|
"writable_zones": ["proposals"],
|
|
905
898
|
"propose_zone": "proposals",
|
|
906
899
|
"latest_seq": 1842
|
|
@@ -908,9 +901,9 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
908
901
|
}
|
|
909
902
|
```
|
|
910
903
|
|
|
911
|
-
`read_verbs` is derived from the MCP verb catalog — the verbs the agent can actually call over its transport — so it lists the read/discovery verbs (`schema_show` for an entry's field shape, `rule_explain` for its
|
|
904
|
+
`read_verbs` is derived from the MCP verb catalog — the verbs the agent can actually call over its transport — so it lists the read/discovery verbs (`schema_show` for an entry's field shape, `rule_explain` for its lifecycle/guard policy, and the graph reads `where`/`deps`/`rdeps`, ADR 0060) and never the CLI-only `audit`/`doctor`, nor `freshness` (the Ruby-only internal lifecycle scan, ADR 0085) (ADR 0056). An agent learns an entry's `_meta` shape by calling the `schema_show` verb before a `put`/`propose`, not by shelling out to a CLI. The graph reads `deps`/`rdeps` return a structured `{key, deps}`/`{key, rdeps}` envelope on every surface (CLI, Ruby, MCP) — a hash, not a bare array, consistent with the other structured read responses such as `where` (ADR 0060 amendment).
|
|
912
905
|
|
|
913
|
-
The agent's MCP write surface includes the single-key `
|
|
906
|
+
The agent's MCP write surface includes the single-key `key_delete` and `key_mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment; the single-key tools were renamed from `delete`/`mv` to share the `key_` family stem in ADR 0082, which also removed the `migrate` YAML-plan orchestrator — its `zone_mv`/`key_mv_prefix`/`key_delete_prefix` ops remain individually callable). All of these apply by default; `dry_run: true` is a uniform opt-in preview that returns a Plan without mutating (ADR 0071 — verbs are actions, dry-run is opt-in on every surface). Single-key `key_delete` additionally accepts an optional `if_etag` optimistic-concurrency check. The blast-radius reads (`where`/`deps`/`rdeps`) remain on MCP so an agent can look before it leaps. The promotion verbs `accept` and `reject` are also on MCP (ADR 0072): they are gated by the `author_held` capability floor, not by transport absence — a default-`agent` connection is refused, while a connection launched as a role holding `author` (`--as`/`TEXTUS_ROLE`/`.textus/role`, resolved once at launch per ADR 0040) can promote, closing the propose→accept loop over one transport. `build` is also on MCP (ADR 0076): it is caller-agnostic and self-elevating — it always runs as the manifest's `build`-capable actor regardless of the calling role, grants no authority over content (build is a pure, idempotent function of already-accepted canon, ADR 0070), and is serialized by a shared single-writer lock across all transports so a concurrent CLI or background build cannot collide with an MCP-triggered one.
|
|
914
907
|
|
|
915
908
|
`latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
|
|
916
909
|
|
|
@@ -929,7 +922,7 @@ The agent's MCP write surface includes the single-key `delete` and `mv` tools al
|
|
|
929
922
|
}
|
|
930
923
|
```
|
|
931
924
|
|
|
932
|
-
`cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `stale` is
|
|
925
|
+
`cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `stale` is computed by the internal lifecycle scan (the former `freshness` verb, now Ruby-only — ADR 0085 folded its agent-facing output into `pulse`). `pending_review` lists all keys in the queue zone. `doctor` is an `{ok, warn, fail}` count summary. `contract_etag` is the `sha256:`-prefixed composite content hash of the contract — the manifest plus hooks and schemas (ADR 0074, via ADR 0025) — for cheap change-detection. `next_due_at` is the soonest upcoming lifecycle deadline across entries (ISO-8601, or `null` if none). `hook_errors` lists hook failures recorded since the cursor. When `--since` is below the oldest available seq (due to audit log rotation), pulse returns `CursorExpired`.
|
|
933
926
|
|
|
934
927
|
**`put` input** (read from stdin when `--stdin` is given):
|
|
935
928
|
|
|
@@ -939,27 +932,9 @@ The agent's MCP write surface includes the single-key `delete` and `mv` tools al
|
|
|
939
932
|
"if_etag": "sha256:8f3c…" }
|
|
940
933
|
```
|
|
941
934
|
|
|
942
|
-
`if_etag` is optional on both `put` and `
|
|
935
|
+
`if_etag` is optional on both `put` and `key_delete`. When provided, the write fails with `etag_mismatch` if the on-disk file's etag differs. When omitted, the write is unconditional (last-writer-wins).
|
|
943
936
|
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
```json
|
|
947
|
-
{
|
|
948
|
-
"verb": "freshness",
|
|
949
|
-
"rows": [
|
|
950
|
-
{ "key": "feeds.upstream.notes",
|
|
951
|
-
"zone": "feeds",
|
|
952
|
-
"last_fetched_at": "2026-05-21T13:21:17Z",
|
|
953
|
-
"age_seconds": 65000,
|
|
954
|
-
"ttl_seconds": 43200,
|
|
955
|
-
"on_stale": "warn",
|
|
956
|
-
"status": "stale",
|
|
957
|
-
"next_due_at": "2026-05-22T01:21:17Z" }
|
|
958
|
-
]
|
|
959
|
-
}
|
|
960
|
-
```
|
|
961
|
-
|
|
962
|
-
Each row reports one entry's verdict (`fresh`, `stale`, `never_fetched`, or `no_policy`) against its matched `fetch:` rule. `textus build` consumes its own staleness signal and executes derived entries' projections under a `build`-holding role (`automation` by default); `--dry-run` prints the plan without executing.
|
|
937
|
+
The lifecycle scan behind `pulse.stale`/`pulse.next_due_at` reports, per entry, one verdict (`fresh`, `expired`, or `no_policy`) plus the matched rule's `on_expire` action, against its matched `lifecycle:` rule. ADR 0085 removed the standalone `freshness` verb that used to render these rows; the scan is now Ruby-only (consumed by `pulse` and the hook context), and human drill-down into a single entry's verdict is `textus get KEY` (carries `stale`/`stale_reason`) plus `textus rule_explain KEY` (the `lifecycle:` ttl + `on_expire`). `textus build` consumes its own staleness signal and executes derived entries' projections under a `build`-holding role (`automation` by default); `--dry-run` prints the plan without executing.
|
|
963
938
|
|
|
964
939
|
`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 a role holding the `author` capability (the trust anchor — `human` by default) may invoke `accept`.
|
|
965
940
|
|
|
@@ -1008,7 +983,7 @@ Given a manifest entry where `key: identity.self` lives in the `identity` zone (
|
|
|
1008
983
|
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.
|
|
1009
984
|
|
|
1010
985
|
**Fixture D — Staleness detection:**
|
|
1011
|
-
Given a manifest entry `intake.notes` matched by a `rules: [{ match: intake.notes,
|
|
986
|
+
Given a manifest entry `intake.notes` matched by a `rules: [{ match: intake.notes, lifecycle: { ttl: 1h, on_expire: warn } }]` block and an envelope on disk whose `_meta.last_fetched_at` is older than `now - ttl`, `textus pulse --output=json` lists `intake.notes` in its `stale` array (the lifecycle scan classifies it `expired`). The scan is pure: producing this verdict does NOT trigger a refresh.
|
|
1012
987
|
|
|
1013
988
|
**Fixture E — Projection build:**
|
|
1014
989
|
Given a manifest entry `derived.catalogs.skills` whose `compute: { kind: 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. The output is content-addressed (no `generated_at` timestamp, ADR 0070), so rebuilding with unchanged sources reproduces it byte-for-byte and writes nothing.
|
|
@@ -1020,7 +995,7 @@ Given a derived entry with a `template` clause referencing a `.mustache` file an
|
|
|
1020
995
|
Given a manifest entry with `publish: { to: [<path>] }`, a successful `textus build` for that entry leaves a plain file at `<path>` whose contents are byte-identical to the in-store artifact at `.textus/zones/<...>`, accompanied by a sentinel at `.textus/.run/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `build` is idempotent.
|
|
1021
996
|
|
|
1022
997
|
**Fixture H — Audit log format:**
|
|
1023
|
-
Every successful write verb (`put`, `
|
|
998
|
+
Every successful write verb (`put`, `key_delete`, `build`, `accept`, `schema migrate`) appends exactly one line per affected key to the audit log, in the canonical format defined in §audit (timestamp, actor role, verb, key, etag-before, etag-after). No write produces zero or multiple lines per key.
|
|
1024
999
|
|
|
1025
1000
|
**Fixture I — Pending → accept:**
|
|
1026
1001
|
Given a proposal entry `proposals.knowledge.self.patch` proposing a change to `knowledge.identity.self`, `textus accept proposals.knowledge.self.patch --as=human` copies the patch body into the target key, deletes the proposal entry, and appends two audit lines (one for the target write, one for the proposals delete) in that order.
|
|
@@ -1066,7 +1041,7 @@ A `textus/3` implementation MUST:
|
|
|
1066
1041
|
- [ ] Refuse writes whose resolved role lacks the capability the target zone-kind requires with `write_forbidden`.
|
|
1067
1042
|
- [ ] Return envelopes matching the shape in §8 exactly (with `_meta`, not `frontmatter`).
|
|
1068
1043
|
- [ ] Use the error codes in §8 and the exit-code table.
|
|
1069
|
-
- [ ] Implement `
|
|
1044
|
+
- [ ] Implement the lifecycle scan behind `pulse` (`stale`/`next_due_at`) and the hook context per §5.11 and §9, walking each entry, matching it against the top-level `rules:` block, and reporting `fresh|expired|no_policy` (plus the `on_expire` action) without invoking any refresh. (ADR 0085: a Ruby-only internal scan — there is no public `freshness` verb.)
|
|
1070
1045
|
- [ ] Pass the conformance fixtures A–I in §12.
|
|
1071
1046
|
|
|
1072
1047
|
A `textus/3` implementation MAY:
|
|
@@ -69,7 +69,7 @@ A derived entry declares a `compute:` block with a `kind:` discriminator. Two ki
|
|
|
69
69
|
to: [docs/people.md] # optional repo-relative byte-copy targets
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
**`compute: { kind: external }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `
|
|
72
|
+
**`compute: { kind: external }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `build` (default: `automation`).
|
|
73
73
|
|
|
74
74
|
```yaml
|
|
75
75
|
- key: artifacts.catalogs.skills
|
|
@@ -88,38 +88,39 @@ Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../
|
|
|
88
88
|
|
|
89
89
|
## Intake and freshness
|
|
90
90
|
|
|
91
|
-
External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler;
|
|
91
|
+
External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler; refresh is on demand via a read-through `get`:
|
|
92
92
|
|
|
93
93
|
```sh
|
|
94
|
-
textus
|
|
95
|
-
textus
|
|
94
|
+
textus get feeds.notion.roadmap --as=automation # refreshes if stale
|
|
95
|
+
textus pulse --output=json # `stale` lists expired entries; `next_due_at` is the soonest deadline
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
Lifecycle budgets live in the top-level `rules:` block, matched by glob:
|
|
99
99
|
|
|
100
100
|
```yaml
|
|
101
101
|
rules:
|
|
102
102
|
- match: feeds.notion.**
|
|
103
|
-
|
|
103
|
+
lifecycle: { ttl: 6h, on_expire: refresh } # refresh | warn | drop | archive
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
A typical scheduled
|
|
106
|
+
A typical scheduled integration reads each expired feed (a read-through `get`
|
|
107
|
+
refreshes it in-process):
|
|
107
108
|
|
|
108
109
|
```sh
|
|
109
|
-
textus
|
|
110
|
+
textus get feeds.notion.roadmap --as=automation # in cron / CI
|
|
110
111
|
```
|
|
111
112
|
|
|
112
113
|
See [`./zones.md` §6](zones.md) for the full intake contract and [`../how-to/writing-hooks.md`](../how-to/writing-hooks.md) for writing custom handlers.
|
|
113
114
|
|
|
114
|
-
### Read vs.
|
|
115
|
+
### Read vs. refresh
|
|
115
116
|
|
|
116
117
|
There is one public read operation (ADR 0062):
|
|
117
118
|
|
|
118
119
|
| Operation | Behaviour | Use for |
|
|
119
120
|
|-----------|-----------|---------|
|
|
120
|
-
| `ops.get` | Read-through by default —
|
|
121
|
+
| `ops.get` | Read-through by default — refreshes on stale per the entry's `lifecycle` rule when `on_expire: refresh`; degrades to a pure on-disk read when the key has no lifecycle rule. Pass `fetch: false` (CLI `--no-fetch`, MCP `{fetch:false}`) for an explicit pure read | all callers, including interactive reads, dashboards, and scripts that want the freshest obtainable envelope |
|
|
121
122
|
|
|
122
|
-
Build pipelines and other internal callers that must never trigger a
|
|
123
|
+
Build pipelines and other internal callers that must never trigger a refresh (materializer, projection, schema tooling, accept/reject/publish, uid, validator) construct `Read::Get` directly with the method default `fetch: false` — a pure, orchestrator-free read. They bypass the verb-dispatch injection that sets `fetch: true`, so they always get pure reads without any extra argument.
|
|
123
124
|
|
|
124
125
|
## Body content
|
|
125
126
|
|
|
@@ -145,4 +146,4 @@ The user-facing CLI surface, the wire envelope shape, and the protocol version (
|
|
|
145
146
|
|
|
146
147
|
- **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.
|
|
147
148
|
- **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.
|
|
148
|
-
- **CI**: run `textus
|
|
149
|
+
- **CI**: run `textus doctor` (the `generator_drift` check) or `textus pulse` (the `stale` list) in CI to catch drift between derived entries and their sources.
|