textus 0.42.0 → 0.43.1
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 +34 -0
- data/README.md +2 -2
- data/SPEC.md +24 -28
- data/docs/architecture/README.md +1 -1
- data/docs/reference/conventions.md +11 -10
- data/lib/textus/boot.rb +18 -11
- data/lib/textus/doctor/check/illegal_keys.rb +2 -20
- data/lib/textus/manifest/entry/base.rb +0 -1
- data/lib/textus/manifest/entry/nested.rb +3 -5
- data/lib/textus/manifest/entry/parser.rb +4 -1
- data/lib/textus/manifest/entry/publish/mode.rb +6 -0
- data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
- data/lib/textus/manifest/entry/publish/tree.rb +5 -9
- data/lib/textus/manifest/entry/publish.rb +16 -14
- data/lib/textus/manifest/entry/validators/publish.rb +4 -2
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/resolver.rb +9 -25
- data/lib/textus/manifest/schema.rb +50 -10
- data/lib/textus/mcp/catalog.rb +16 -2
- data/lib/textus/version.rb +1 -1
- metadata +1 -2
- data/lib/textus/manifest/entry/validators/index_filename.rb +0 -47
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c26661afb6c59f813ccdb737e56666be242a14e3315a100348dd8514a41e78a
|
|
4
|
+
data.tar.gz: 26ff3e7e8cd94a545cdcabe964c6e8c7311fe0179a24a13b0ab298eab7f2bf34
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: db6b8047ba7d7c79ad78a653944709d7cc7cfd5c22a4baae116e97c8d6fea48c409788c835e1c1f83c7684a58da089b08a0525a44791de92875d1d2bb0c26a76
|
|
7
|
+
data.tar.gz: 26e9d74398110f4d34dd59c837901170e5d80a847268f5edee45010f4793c51e1f47737d5d3c85b0098fa06b1334e00bf1038330a12a1061c9957d045e07638c
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,40 @@ 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.43.1 — 2026-06-02 — `boot` agent surface derives from the MCP catalog ([ADR 0056](docs/architecture/decisions/0056-boot-quickstart-speaks-the-mcp-catalog.md))
|
|
13
|
+
|
|
14
|
+
No `textus/3` wire-format change — `boot`'s agent-orientation fields are corrected to match the verbs an MCP agent can actually call.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **`boot.agent_quickstart.read_verbs` had drifted from the MCP catalog ([ADR 0056](docs/architecture/decisions/0056-boot-quickstart-speaks-the-mcp-catalog.md)).** It was a hand-maintained list that advertised CLI-only read verbs an MCP agent cannot call (`audit`, `freshness`, `doctor`) while omitting the ones it can and needs (`schema`, `rules`). It now **derives** from `MCP::Catalog` (`get list pulse schema boot rules`) and is reconciled by a guard spec, so it can no longer advertise an uncallable verb or omit a callable one. This is what led downstream skills to shell out to a CLI for schema discovery instead of calling the `schema` verb.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **`boot.agent_protocol.recipes` reference verbs, not CLI strings ([ADR 0056](docs/architecture/decisions/0056-boot-quickstart-speaks-the-mcp-catalog.md)).** Each recipe step names a verb (`get KEY`, `schema KEY`, `put KEY`, `propose KEY`, `fetch_all`) or a plain build step, instead of a `textus …` / `echo … | textus …` shell line — each transport frames the verb itself (CLI vs MCP tool). Keeps shell syntax out of the surface an MCP agent reads. `human_steps` still name `accept` (the author-only transition, not an MCP tool, by design).
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **ADR 0054 (Proposed, no implementation): entry-level `desc`.** Records the decision to add an optional one-line `desc:` to manifest entries — surfaced in `boot.entries`/`list` — turning the manifest into a navigable index so an agent finds the right data without a caller hardcoding the key. Pre-registers ADR 0055 (a `find`/search verb) as the evidence-triggered follow-up. Documentation only in this release.
|
|
27
|
+
|
|
28
|
+
## 0.43.0 — 2026-06-02 — Typed `publish:` block + remove `index_filename` ([ADR 0052](docs/architecture/decisions/0052-typed-publish-block.md), [0053](docs/architecture/decisions/0053-remove-index-filename.md))
|
|
29
|
+
|
|
30
|
+
No `textus/3` wire-format change. Two breaking (pre-1.0) changes on the publish/enumeration surface: the two top-level publish keys become one typed `publish:` block, and the unused `index_filename:` enumeration feature is removed. Both fail at load with a migration-pointing message.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **BREAKING (pre-1.0): `publish_to:`/`publish_tree:` folded into one typed `publish:` block ([ADR 0052](docs/architecture/decisions/0052-typed-publish-block.md)).** Publishing is now configured by `publish: { to: [...] }` (file fan-out) **xor** `publish: { tree: "dir" }` (subtree mirror), mirroring the ADR 0049 internal sum type at the manifest layer and giving a future third mode a namespace instead of a third top-level key. Surface-only — the `Publish::*` modes, `:file_published` event, and build envelope are unchanged. A manifest using the flat `publish_to:`/`publish_tree:` keys **fails at load** with the replacement (`use publish: { to: [...] }` / `{ tree: "..." }`). This is grouping/extensibility, not a new invariant: a block with both `to:` and `tree:` is still caught by the one exclusivity guard in `Publish.resolve`.
|
|
35
|
+
|
|
36
|
+
### Removed
|
|
37
|
+
|
|
38
|
+
- **BREAKING (pre-1.0): the `index_filename:` enumeration feature is removed ([ADR 0053](docs/architecture/decisions/0053-remove-index-filename.md)).** `index_filename:` let a nested entry enumerate *directories* as addressable keys via a fixed index file (e.g. `SKILL.md`). It had zero real usage, its only motivating consumer (`EachDir` per-leaf publish) was removed in 0.42.0, and it could not compose with `publish_tree` (mutually exclusive). A nested entry now always enumerates each file under its tree as a key (key segments derived from the path, extension stripped). A manifest declaring `index_filename:` **fails at load** with a migration message; to mirror a directory of files to a consumer path without enumerating them as keys, use `publish: { tree: "..." }`. Native skill authoring (ADR 0050) is unaffected — it rides `publish_tree`, which never enumerated an index. The shallowest-index-wins claiming logic (ADR 0046 D5) and the `index_filename ⊥ publish_tree` guard go with it.
|
|
39
|
+
|
|
40
|
+
No `textus/3` wire-format change — repo-local publish behaviour only.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **`publish_tree` files are now opaque in *every* path, not just the Publisher ([ADR 0047](docs/architecture/decisions/0047-publish-tree-keyless-subtree-mirror.md)).** A keyless subtree mirror carrying non-key-legal filenames (uppercase `SKILL.md`, `README`) tripped `doctor`'s `key.illegal` and red-gated commits, even though publish itself mirrored the files correctly — `doctor`'s `IllegalKeys` and `Resolver#enumerate_nested` still key-walked them, contradicting ADR 0047's "no keys" contract. The resolved publish mode now answers `Publish::Mode#keyless?` (true only for `Tree`); both paths consult it and skip enumerating a keyless mirror's files. A `publish_tree` subtree with uppercase filenames stays `doctor`-green and still mirrors; a non-publish nested entry still flags illegal segments as before.
|
|
45
|
+
|
|
12
46
|
## 0.42.0 — 2026-06-02 — Remove `publish_each`: collapse publish to two modes ([ADR 0051](docs/architecture/decisions/0051-remove-publish-each.md))
|
|
13
47
|
|
|
14
48
|
No `textus/3` wire-format change. **Breaking (pre-1.0):** the `publish_each:` manifest key is removed; a manifest declaring it now fails at load. The publish surface collapses to two modes — `publish_to:` (fixed paths) and `publish_tree:` (whole-subtree mirror) — plus `None`.
|
data/README.md
CHANGED
|
@@ -182,7 +182,7 @@ For a worked store — knowledge entries, a staged proposal, schemas, a template
|
|
|
182
182
|
|
|
183
183
|
## What's shipped
|
|
184
184
|
|
|
185
|
-
- **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; `
|
|
185
|
+
- **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; a typed `publish:` block (`to:` for file fan-out, `tree:` for a whole-subtree mirror) byte-copies derived files to their consumer paths. ([SPEC §5.2–5.3](SPEC.md))
|
|
186
186
|
- **Stable identity.** Auto-minted `uid:` survives writes and `textus key mv`; reorganising never breaks references.
|
|
187
187
|
- **Capability × zone-kind gate.** Writes carry `--as=<role>`; a role may write a zone iff it holds the capability the zone's `kind:` requires (`canon`→`author`, `workspace`→`keep`, `quarantine`→`fetch`, `queue`→`propose`, `derived`→`build`). The wrong role gets `write_forbidden` naming the capability needed and the roles that hold it. ([SPEC §5](SPEC.md))
|
|
188
188
|
- **Agent loop.** `textus boot` orients a fresh session; `textus pulse --since=N` is the per-turn heartbeat (changed entries, stale keys, pending proposals). ([docs/how-to/agents-mcp.md](docs/how-to/agents-mcp.md))
|
|
@@ -203,7 +203,7 @@ Derived entries declare `compute: { kind: projection, select: ..., pluck: ..., s
|
|
|
203
203
|
|
|
204
204
|
For externally-generated entries, declare `compute: { kind: external, sources: [...] }` — textus tracks the declared sources for staleness; the build automation produces the file.
|
|
205
205
|
|
|
206
|
-
`
|
|
206
|
+
Publishing is one typed `publish:` block (ADR 0052). `publish: { to: [path, ...] }` byte-copies a single derived file to one or more targets. `publish: { tree: "dir" }` on a nested entry mirrors its whole stored subtree to one target directory, preserving layout (path-driven — no keys or template variables). Sentinels for every published file live under `.textus/sentinels/`. See SPEC §5.2, §5.3, §5.12.
|
|
207
207
|
|
|
208
208
|
## Extension points
|
|
209
209
|
|
data/SPEC.md
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
- [5.2 Compute layer (derived entries)](#52-compute-layer-derived-entries)
|
|
25
25
|
- [5.2.1 Projection compute](#521-projection-compute-kind-projection)
|
|
26
26
|
- [5.2.2 External compute](#522-external-compute-kind-external)
|
|
27
|
-
- [5.3 Publish layer](#53-publish-layer-
|
|
27
|
+
- [5.3 Publish layer](#53-publish-layer-publish)
|
|
28
28
|
- [5.4 Intake](#54-intake-declared-fetched-via-registered-intake-handler)
|
|
29
29
|
- [5.5 Pending / accept workflow](#55-pending--accept-workflow)
|
|
30
30
|
- [5.6 Audit log](#56-audit-log)
|
|
@@ -100,7 +100,7 @@ textus is organized as five composable layers. Each layer has a single responsib
|
|
|
100
100
|
| L1 | **Store** | Plain-file backend: `.textus/zones/<zone>/...` with YAML frontmatter + Markdown body, addressed by dotted keys, schema-validated, etag-versioned. |
|
|
101
101
|
| L2 | **Sources** | Declared external inputs (the `feeds` zone in the default scaffold; any `quarantine` zone, writable by a role with `fetch`): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external automation fetches and pipes results through `textus put`. |
|
|
102
102
|
| L3 | **Compute** | Pure transforms from store entries to derived entries. Projections (select/pluck/sort/limit/format) plus a vendored Mustache template subset. No shell execution. |
|
|
103
|
-
| L4 | **Publish** | Byte-for-byte file copy from derived entries to repo-relative paths declared via `
|
|
103
|
+
| L4 | **Publish** | Byte-for-byte file copy from derived entries to repo-relative paths declared via `publish: { to: [...] }`. The in-store artifact is the consumer-shaped output; the published file is an identical copy. A sentinel under `.textus/sentinels/<target-rel-path>.textus-managed.json` records the source, sha256, and `mode: "copy"`. |
|
|
104
104
|
| L5 | **Consumers** | Anything that reads the published files or calls the CLI — editors, LLM tools, MCP servers, CI jobs, dashboards. textus is agnostic about who consumes; the envelope is the contract. |
|
|
105
105
|
|
|
106
106
|
## 2. Goals and non-goals
|
|
@@ -197,14 +197,14 @@ entries:
|
|
|
197
197
|
path: knowledge/network/org
|
|
198
198
|
zone: knowledge
|
|
199
199
|
schema: person
|
|
200
|
-
owner:
|
|
200
|
+
owner: human:network
|
|
201
201
|
nested: true
|
|
202
202
|
|
|
203
203
|
- key: artifacts.catalogs.people
|
|
204
204
|
path: artifacts/catalogs/people.md
|
|
205
205
|
zone: artifacts
|
|
206
206
|
schema: null
|
|
207
|
-
owner:
|
|
207
|
+
owner: automation:build
|
|
208
208
|
|
|
209
209
|
rules:
|
|
210
210
|
- match: feeds.**
|
|
@@ -228,21 +228,11 @@ Zone names are conventional — write authority comes from each zone's declared
|
|
|
228
228
|
| `yaml` | `.yaml` or `.yml` required | optional (escape hatch) | optional (top-level keys) |
|
|
229
229
|
| `text` | `.txt` or no extension | required for derived | MUST be null |
|
|
230
230
|
|
|
231
|
-
For `nested: true`, the recursive glob matches the format's extension (markdown→`**/*.md`, json→`**/*.json`, yaml→`**/*.{yaml,yml}`, text→`**/*.txt`). All files under one nested entry share one format and one schema.
|
|
231
|
+
For `nested: true`, the recursive glob matches the format's extension (markdown→`**/*.md`, json→`**/*.json`, yaml→`**/*.{yaml,yml}`, text→`**/*.txt`). All files under one nested entry share one format and one schema. Each matching file is enumerated as its own key, with the key segments derived from the path relative to the entry (extension stripped). A nested entry that instead mirrors a whole directory of files to a consumer path — without enumerating any of them as keys — uses `publish: { tree: }` (below); its files are opaque payload. (The former `index_filename:` directory-keyed enumeration was removed in 0.43.0 — ADR 0053.)
|
|
232
232
|
|
|
233
|
-
**
|
|
233
|
+
**The `publish:` block (ADR 0052).** Publishing is configured by one typed `publish:` block with exactly one of two sub-keys — `publish: { to: [...] }` (file fan-out, §5.3) **xor** `publish: { tree: "dir" }` (subtree mirror, below). Setting both is an error. The legacy top-level `publish_to:` / `publish_tree:` keys are rejected at load with a migration message.
|
|
234
234
|
|
|
235
|
-
|
|
236
|
-
- key: skills
|
|
237
|
-
path: skills
|
|
238
|
-
zone: skills
|
|
239
|
-
nested: true
|
|
240
|
-
index_filename: SKILL.md
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
A file at `.textus/zones/skills/ask/SKILL.md` enumerates as `skills.ask`; `.textus/zones/skills/ask/references/algorithm.md` is not enumerated. Resolving `skills.ask` returns the `SKILL.md` path. `index_filename:` requires `nested: true`; the value must be a bare basename (no slashes). `index_filename:` is a pure *enumeration* feature — it selects which file is the addressable row per directory and is independent of publishing (ADR 0051).
|
|
244
|
-
|
|
245
|
-
**Subtree mirror (`publish_tree:`).** A nested manifest entry MAY declare `publish_tree:` to mirror its entire stored subtree (`zones/<path>/**`) to a single target directory, preserving relative layout (case and extension preserved). It is **path-driven, not key-driven**: no keys are enumerated, no template variables are interpreted, and the mirrored files are opaque payload (never addressable). The entry's `ignore:` globs (§4, ADR 0042) filter the walk; each mirrored file gets its own sentinel; and on every build the whole target directory is pruned of textus-managed files the current source no longer produces (unmanaged files are never touched). `publish_tree:` is mutually exclusive with `publish_to:`, and incompatible with `index_filename:`. When a `publish_tree:` target directory overlaps a `derived` entry's `publish_to:` (e.g. a derived `SKILL.md` written into the mirrored dir), the `publish_tree:` entry **must** `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap`. See ADR 0047.
|
|
235
|
+
**Subtree mirror (`publish: { tree: }`).** A nested manifest entry MAY declare `publish: { tree: "dir" }` to mirror its entire stored subtree (`zones/<path>/**`) to a single target directory, preserving relative layout (case and extension preserved). It is **path-driven, not key-driven**: no keys are enumerated, no template variables are interpreted, and the mirrored files are opaque payload (never addressable). The entry's `ignore:` globs (§4, ADR 0042) filter the walk; each mirrored file gets its own sentinel; and on every build the whole target directory is pruned of textus-managed files the current source no longer produces (unmanaged files are never touched). When a `publish.tree` target directory 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`. See ADR 0047.
|
|
246
236
|
|
|
247
237
|
```yaml
|
|
248
238
|
- key: working.skills
|
|
@@ -250,7 +240,8 @@ A file at `.textus/zones/skills/ask/SKILL.md` enumerates as `skills.ask`; `.text
|
|
|
250
240
|
zone: working
|
|
251
241
|
schema: skill
|
|
252
242
|
nested: true
|
|
253
|
-
|
|
243
|
+
publish:
|
|
244
|
+
tree: "skills"
|
|
254
245
|
ignore: ["*.tmp", ".DS_Store"]
|
|
255
246
|
```
|
|
256
247
|
|
|
@@ -413,7 +404,7 @@ A derived entry that is produced by a build tool *outside* textus — `rake`, `j
|
|
|
413
404
|
- key: output.catalogs.skills
|
|
414
405
|
path: output/catalogs/skills.md
|
|
415
406
|
zone: output
|
|
416
|
-
owner:
|
|
407
|
+
owner: automation:catalog-skills
|
|
417
408
|
compute:
|
|
418
409
|
kind: external
|
|
419
410
|
command: "rake catalog:skills" # informational; external automation invokes it
|
|
@@ -444,21 +435,24 @@ generated:
|
|
|
444
435
|
|
|
445
436
|
`kind: external` and `kind: projection` are alternatives — exactly one per entry. Templates are not required for `kind: external`: the external automation produces the bytes directly.
|
|
446
437
|
|
|
447
|
-
### 5.3 Publish layer (`
|
|
438
|
+
### 5.3 Publish layer (`publish:`)
|
|
439
|
+
|
|
440
|
+
Publishing is configured by one typed `publish:` block with exactly one sub-key (ADR 0052): `to:` (file fan-out) **xor** `tree:` (subtree mirror). Setting both is an error; the legacy top-level `publish_to:` / `publish_tree:` keys are rejected at load with a migration message.
|
|
448
441
|
|
|
449
|
-
A derived entry MAY declare `
|
|
442
|
+
A derived entry MAY declare `publish: { to: [...] }`, listing one or more destination paths relative to the project root:
|
|
450
443
|
|
|
451
444
|
```yaml
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
445
|
+
publish:
|
|
446
|
+
to:
|
|
447
|
+
- CLAUDE.md
|
|
448
|
+
- .ai/instructions.md
|
|
455
449
|
```
|
|
456
450
|
|
|
457
451
|
When the entry is recomputed, textus copies the in-store file byte-for-byte to each destination. The in-store artifact under `.textus/zones/<output-zone>/…` is already the consumer-shaped output (per the format strategy — see §5.x), so publish is a verbatim file copy with no parsing or stripping.
|
|
458
452
|
|
|
459
453
|
A sentinel is written for each published file at `<store_root>/sentinels/<target-relative-to-repo>.textus-managed.json`, recording `source`, `target`, the target's sha256, and `mode: "copy"`. Sentinels live under the store rather than beside the consumer file so target directories stay clean. The sentinel exists so out-of-band edits can be detected on the next publish — textus refuses to clobber a destination that is not either missing, marked as managed, or **byte-identical to the source being published**. An identical destination is *adopted*: its sentinel is written and management proceeds (the copy is a content no-op), so an artifact tree already on disk onboards without a manual delete. An unmanaged destination whose content **differs**, or any unmanaged symlink, is still refused (ADR 0050). Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
|
|
460
454
|
|
|
461
|
-
**Subtree mirror.** A nested entry MAY declare `
|
|
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>/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).
|
|
462
456
|
|
|
463
457
|
### 5.4 Intake (declared, fetched via registered intake handler)
|
|
464
458
|
|
|
@@ -806,7 +800,7 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
|
|
|
806
800
|
"protocol": "textus/3",
|
|
807
801
|
"key": "knowledge.network.org.jane",
|
|
808
802
|
"zone": "knowledge",
|
|
809
|
-
"owner": "
|
|
803
|
+
"owner": "human:network",
|
|
810
804
|
"path": "/absolute/path/to/.textus/zones/knowledge/network/org/jane.md",
|
|
811
805
|
"format": "markdown",
|
|
812
806
|
"_meta": { "name": "jane", "relationship": "peer", "org": "acme" },
|
|
@@ -904,7 +898,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
904
898
|
```json
|
|
905
899
|
{
|
|
906
900
|
"agent_quickstart": {
|
|
907
|
-
"read_verbs": ["
|
|
901
|
+
"read_verbs": ["get", "list", "pulse", "schema", "boot", "rules"],
|
|
908
902
|
"write_verbs": ["put KEY --as=agent --stdin"],
|
|
909
903
|
"writable_zones": ["proposals"],
|
|
910
904
|
"propose_zone": "proposals",
|
|
@@ -913,6 +907,8 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
913
907
|
}
|
|
914
908
|
```
|
|
915
909
|
|
|
910
|
+
`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` for an entry's field shape, `rules` for its freshness/guard policy) and never the CLI-only `audit`/`freshness`/`doctor` (ADR 0056). An agent learns an entry's `_meta` shape by calling the `schema` verb before a `put`/`propose`, not by shelling out to a CLI.
|
|
911
|
+
|
|
916
912
|
`latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
|
|
917
913
|
|
|
918
914
|
**`textus pulse` output shape:**
|
|
@@ -1018,7 +1014,7 @@ Given a manifest entry `derived.catalogs.skills` whose `compute: { kind: project
|
|
|
1018
1014
|
Given a derived entry with a `template` clause referencing a `.mustache` file and inputs drawn from other keys, `textus build` produces a body whose contents match the expected rendered output byte-for-byte (after trailing-newline normalization).
|
|
1019
1015
|
|
|
1020
1016
|
**Fixture G — Copy publish:**
|
|
1021
|
-
Given a manifest entry with `
|
|
1017
|
+
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/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `build` is idempotent.
|
|
1022
1018
|
|
|
1023
1019
|
**Fixture H — Audit log format:**
|
|
1024
1020
|
Every successful write verb (`put`, `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.
|
data/docs/architecture/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Textus architecture
|
|
2
2
|
|
|
3
3
|
> **Explanation** · for contributors · **read this first** for orientation before SPEC
|
|
4
|
-
> **SSoT for** the Ruby implementation layout (layers, container, ports, read/write/fetch paths) · **reviewed** 2026-
|
|
4
|
+
> **SSoT for** the Ruby implementation layout (layers, container, ports, read/write/fetch paths) · **reviewed** 2026-06 (v0.43)
|
|
5
5
|
|
|
6
6
|
```mermaid
|
|
7
7
|
flowchart TD
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Conventions
|
|
2
2
|
|
|
3
3
|
> **Reference** · for integrators · **read when** you're shaping a `.textus/` tree and want the idiomatic choices
|
|
4
|
-
> **SSoT for** idiomatic key naming, schema design, and automation integration · **reviewed** 2026-
|
|
4
|
+
> **SSoT for** idiomatic key naming, schema design, and automation integration · **reviewed** 2026-06 (v0.43)
|
|
5
5
|
|
|
6
6
|
Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and integrating with build automation. The spec ([`../../SPEC.md`](../../SPEC.md)) defines what's enforceable; this document captures what's *idiomatic*.
|
|
7
7
|
|
|
@@ -39,11 +39,11 @@ Inside `knowledge/`, group by **domain** (identity, people, projects, decisions,
|
|
|
39
39
|
|
|
40
40
|
## Owner strings
|
|
41
41
|
|
|
42
|
-
The `owner:` field in the manifest is **advisory metadata**, not an ACL. Use it to label *who's expected to write here
|
|
42
|
+
The `owner:` field in the manifest is **advisory metadata**, not an ACL. Use it to label *who's expected to write here*. The form is `<archetype>` or `<archetype>:<subject>`; the archetype must be one of `human`, `agent`, `automation` (validated at load — ADR 0045), and the subject is free-form:
|
|
43
43
|
|
|
44
|
-
- `
|
|
44
|
+
- `human:network` — humans curate
|
|
45
45
|
- `agent:planner` — a specific named agent
|
|
46
|
-
- `
|
|
46
|
+
- `automation:catalog-skills` — a specific build job
|
|
47
47
|
|
|
48
48
|
Tooling around `git blame` or audit logs may filter on owner; the gem itself only echoes it back in envelopes.
|
|
49
49
|
|
|
@@ -58,14 +58,15 @@ A derived entry declares a `compute:` block with a `kind:` discriminator. Two ki
|
|
|
58
58
|
path: artifacts/catalogs/people.md
|
|
59
59
|
zone: artifacts
|
|
60
60
|
schema: null
|
|
61
|
-
owner:
|
|
61
|
+
owner: automation:catalog-people
|
|
62
62
|
compute:
|
|
63
63
|
kind: projection
|
|
64
64
|
select: knowledge.network.org # prefix or list of prefixes
|
|
65
65
|
pluck: [name, relationship, org]
|
|
66
66
|
sort_by: name
|
|
67
67
|
template: people.mustache # under .textus/templates/
|
|
68
|
-
|
|
68
|
+
publish:
|
|
69
|
+
to: [docs/people.md] # optional repo-relative byte-copy targets
|
|
69
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 `textus freshness` can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `build` (default: `automation`).
|
|
@@ -74,7 +75,7 @@ A derived entry declares a `compute:` block with a `kind:` discriminator. Two ki
|
|
|
74
75
|
- key: artifacts.catalogs.skills
|
|
75
76
|
path: artifacts/catalogs/skills.md
|
|
76
77
|
zone: artifacts
|
|
77
|
-
owner:
|
|
78
|
+
owner: automation:catalog-skills
|
|
78
79
|
compute:
|
|
79
80
|
kind: external
|
|
80
81
|
command: "rake catalog:skills" # informational; the automation invokes it
|
|
@@ -83,7 +84,7 @@ A derived entry declares a `compute:` block with a `kind:` discriminator. Two ki
|
|
|
83
84
|
|
|
84
85
|
The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `compute.sources` — same list, recorded twice so a diff proves what was consumed.
|
|
85
86
|
|
|
86
|
-
Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md). Transforms (`compute.transform:`) and subtree publishing (`
|
|
87
|
+
Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md). Transforms (`compute.transform:`) and subtree publishing (`publish: { tree: }`) are also covered there.
|
|
87
88
|
|
|
88
89
|
## Intake and freshness
|
|
89
90
|
|
|
@@ -105,7 +106,7 @@ rules:
|
|
|
105
106
|
A typical scheduled-fetch integration shells the `fetch stale` sweep itself:
|
|
106
107
|
|
|
107
108
|
```sh
|
|
108
|
-
textus fetch stale --zone=
|
|
109
|
+
textus fetch stale --zone=feeds --as=automation # in cron / CI
|
|
109
110
|
```
|
|
110
111
|
|
|
111
112
|
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.
|
|
@@ -136,7 +137,7 @@ For multi-writer environments, **always pass `if_etag`** on `put`. The gem treat
|
|
|
136
137
|
The application layer is organised around three shapes — `Manifest` as a composition record, a single `Container` capability record handed to every use case, and a split envelope reader/writer. See [ADR 0018](../architecture/decisions/0018-manifest-carving.md), [ADR 0017](../architecture/decisions/0017-envelope-io-split.md), [ADR 0022](../architecture/decisions/0022-container-call-dispatcher.md), and [ADR 0023](../architecture/decisions/0023-uniform-use-case-shape.md).
|
|
137
138
|
|
|
138
139
|
- **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(zone)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
|
|
139
|
-
- **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/{read,write,maintenance}/` with `def initialize(container:, call:)` and a `#call(...)` method; verbs are looked up in the static `Textus::Dispatcher::VERBS` table. `Container` is a `Data.define` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc
|
|
140
|
+
- **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/{read,write,maintenance}/` with `def initialize(container:, call:)` and a `#call(...)` method; verbs are looked up in the static `Textus::Dispatcher::VERBS` table. `Container` is a `Data.define` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). A use case that emits events derives its `Hooks::Context` from `(container, call)` — nothing is injected. Use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer. `store.as(role)` returns a `RoleScope` that forwards verbs to the dispatcher.
|
|
140
141
|
- **Write path is split**: `Envelope::IO::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Envelope::IO::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
|
|
141
142
|
|
|
142
143
|
The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/3`) are unchanged.
|
data/lib/textus/boot.rb
CHANGED
|
@@ -127,7 +127,10 @@ module Textus
|
|
|
127
127
|
propose_zone = manifest.policy.propose_zone_for(agent_role)
|
|
128
128
|
|
|
129
129
|
{
|
|
130
|
-
|
|
130
|
+
# Derived from the MCP catalog (ADR 0056): the agent's real read surface,
|
|
131
|
+
# so the quickstart can neither advertise a verb the agent cannot call
|
|
132
|
+
# (audit/freshness/doctor are CLI-only) nor omit one it can (schema/rules).
|
|
133
|
+
"read_verbs" => Textus::MCP::Catalog.read_verbs,
|
|
131
134
|
"write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
|
|
132
135
|
"writable_zones" => writable_zones,
|
|
133
136
|
"propose_zone" => propose_zone,
|
|
@@ -135,6 +138,10 @@ module Textus
|
|
|
135
138
|
}
|
|
136
139
|
end
|
|
137
140
|
|
|
141
|
+
# Recipes reference verbs, not a transport's CLI strings (ADR 0056): every
|
|
142
|
+
# step names a verb the agent can call (each transport frames it — CLI as
|
|
143
|
+
# `textus get KEY`, MCP as the `get` tool) or is a plain build step. This
|
|
144
|
+
# keeps shell lines out of the surface an MCP agent reads.
|
|
138
145
|
def self.recipes(manifest)
|
|
139
146
|
queue = manifest.policy.queue_zone
|
|
140
147
|
feeds = zone_label(manifest, :quarantine, "the quarantine zone")
|
|
@@ -142,32 +149,32 @@ module Textus
|
|
|
142
149
|
"read" => {
|
|
143
150
|
"purpose" => "find and read an entry",
|
|
144
151
|
"steps" => [
|
|
145
|
-
"
|
|
146
|
-
"
|
|
152
|
+
"list (zone:, prefix:) — discover keys without reading bodies",
|
|
153
|
+
"get KEY — returns the entry envelope",
|
|
147
154
|
],
|
|
148
155
|
},
|
|
149
156
|
"write" => {
|
|
150
157
|
"purpose" => "create or update an entry",
|
|
151
158
|
"steps" => [
|
|
152
|
-
"
|
|
153
|
-
"
|
|
154
|
-
"
|
|
159
|
+
"schema KEY — learn the _meta field shape (required, optional, field types) before writing",
|
|
160
|
+
"assemble an envelope: { _meta: {…}, body: \"…\" }",
|
|
161
|
+
"put KEY — persist it (role-gated); pass if_etag to guard a concurrent edit",
|
|
155
162
|
],
|
|
156
163
|
},
|
|
157
164
|
"propose" => {
|
|
158
165
|
"purpose" => "agent suggests a change for human review",
|
|
159
166
|
"agent_steps" => [
|
|
160
|
-
"
|
|
167
|
+
"propose KEY — writes the change into the #{queue} zone for review",
|
|
161
168
|
],
|
|
162
169
|
"human_steps" => [
|
|
163
|
-
"
|
|
170
|
+
"accept #{queue}.KEY — promotes the proposal into its target zone",
|
|
164
171
|
],
|
|
165
172
|
},
|
|
166
173
|
"fetch" => {
|
|
167
|
-
"purpose" => "
|
|
174
|
+
"purpose" => "refresh stale quarantine-zone caches from their declared intake",
|
|
168
175
|
"steps" => [
|
|
169
|
-
"
|
|
170
|
-
"
|
|
176
|
+
"pulse — its `stale` list names entries past their ttl",
|
|
177
|
+
"fetch_all (zone: #{feeds}) — re-pull the stale entries",
|
|
171
178
|
],
|
|
172
179
|
},
|
|
173
180
|
}
|
|
@@ -6,12 +6,12 @@ module Textus
|
|
|
6
6
|
out = []
|
|
7
7
|
manifest.data.entries.each do |entry|
|
|
8
8
|
next unless entry.nested?
|
|
9
|
+
next if entry.publish_mode.keyless? # publish_tree files are opaque payload, never keys (ADR 0047)
|
|
9
10
|
|
|
10
11
|
base = File.join(root, "zones", entry.path)
|
|
11
12
|
next unless File.directory?(base)
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
index_fn ? check_index_paths(entry, index_fn, base, out) : check_all_paths(entry, base, out)
|
|
14
|
+
check_all_paths(entry, base, out)
|
|
15
15
|
end
|
|
16
16
|
out
|
|
17
17
|
end
|
|
@@ -31,24 +31,6 @@ module Textus
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
# When the entry uses `index_filename:`, only the parent-directory
|
|
35
|
-
# segments leading to each index file participate in keys. Sibling
|
|
36
|
-
# files and unrelated subtrees are not enumerated and must not be
|
|
37
|
-
# flagged. Each illegal segment is reported once per path. Paths under
|
|
38
|
-
# an ignored subtree (ADR 0042) are excluded before any segment check.
|
|
39
|
-
def check_index_paths(entry, index_fn, base, out)
|
|
40
|
-
Dir.glob(File.join(base, "**", index_fn)).each do |fp|
|
|
41
|
-
rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
42
|
-
next if entry.ignored?(rel)
|
|
43
|
-
|
|
44
|
-
File.dirname(rel).split("/").reject { |s| s.empty? || s == "." }.each do |seg|
|
|
45
|
-
next if seg.match?(Key::Grammar::SEGMENT)
|
|
46
|
-
|
|
47
|
-
out << issue(fp, seg)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
34
|
def issue(abs_path, stem)
|
|
53
35
|
{
|
|
54
36
|
"code" => "key.illegal",
|
|
@@ -6,11 +6,10 @@ module Textus
|
|
|
6
6
|
# Entry::Publish::* — Nested is just the value (attributes + ignore
|
|
7
7
|
# predicate) those modes read.
|
|
8
8
|
class Nested < Base
|
|
9
|
-
attr_reader :
|
|
9
|
+
attr_reader :publish_tree, :ignore
|
|
10
10
|
|
|
11
|
-
def initialize(
|
|
11
|
+
def initialize(publish_tree: nil, ignore: nil, **rest)
|
|
12
12
|
super(**rest)
|
|
13
|
-
@index_filename = index_filename
|
|
14
13
|
@publish_tree = publish_tree
|
|
15
14
|
@ignore = Array(ignore)
|
|
16
15
|
end
|
|
@@ -26,8 +25,7 @@ module Textus
|
|
|
26
25
|
|
|
27
26
|
def self.from_raw(common, raw)
|
|
28
27
|
new(
|
|
29
|
-
|
|
30
|
-
publish_tree: raw["publish_tree"],
|
|
28
|
+
publish_tree: raw.dig("publish", "tree"), # ADR 0052: typed publish block
|
|
31
29
|
ignore: raw["ignore"],
|
|
32
30
|
**common,
|
|
33
31
|
)
|
|
@@ -18,7 +18,10 @@ module Textus
|
|
|
18
18
|
key: key, path: path, zone: zone,
|
|
19
19
|
schema: raw["schema"], owner: raw["owner"],
|
|
20
20
|
format: format,
|
|
21
|
-
|
|
21
|
+
# ADR 0052: publish config is one typed block; the internal
|
|
22
|
+
# publish_to/publish_tree readers (the ADR 0049 modes) are sourced
|
|
23
|
+
# from it (publish_to <- publish.to, publish_tree <- publish.tree).
|
|
24
|
+
publish_to: raw.dig("publish", "to")
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
klass = Entry::REGISTRY[kind] or
|
|
@@ -17,6 +17,12 @@ module Textus
|
|
|
17
17
|
# No shape rules by default — ToPaths/None publish without templating.
|
|
18
18
|
def validate!; end
|
|
19
19
|
|
|
20
|
+
# Whether this entry's subtree files are opaque payload that must
|
|
21
|
+
# never be enumerated as keys. Only Tree (publish_tree, ADR 0047)
|
|
22
|
+
# overrides to true; doctor's IllegalKeys and the resolver consult
|
|
23
|
+
# this so they stop key-walking a keyless mirror's files.
|
|
24
|
+
def keyless? = false
|
|
25
|
+
|
|
20
26
|
private
|
|
21
27
|
|
|
22
28
|
# Expand `rel` under repo_root and confirm it stays inside it.
|
|
@@ -2,8 +2,8 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
module Publish
|
|
5
|
-
#
|
|
6
|
-
# The
|
|
5
|
+
# publish.to: copy the entry's one stored file to each fixed repo path.
|
|
6
|
+
# The behaviour of any entry that declares `publish: { to: [...] }`.
|
|
7
7
|
class ToPaths < Mode
|
|
8
8
|
def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
9
9
|
targets = Array(entry.publish_to)
|
|
@@ -8,6 +8,9 @@ module Textus
|
|
|
8
8
|
# (e.g. a SKILL.md written by a separate entry into the same dir)
|
|
9
9
|
# survives the whole-target prune (ADR 0047 D4).
|
|
10
10
|
class Tree < Mode
|
|
11
|
+
# Mirrored files are opaque payload, never addressable keys (ADR 0047).
|
|
12
|
+
def keyless? = true
|
|
13
|
+
|
|
11
14
|
def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
12
15
|
target_rel = entry.publish_tree
|
|
13
16
|
target_dir = repo_abs(pctx, target_rel)
|
|
@@ -30,20 +33,13 @@ module Textus
|
|
|
30
33
|
|
|
31
34
|
def validate!
|
|
32
35
|
publish_tree = entry.publish_tree
|
|
33
|
-
raise UsageError.new("entry '#{entry.key}':
|
|
34
|
-
|
|
35
|
-
unless entry.index_filename.nil?
|
|
36
|
-
raise UsageError.new(
|
|
37
|
-
"entry '#{entry.key}': index_filename and publish_tree are mutually exclusive — " \
|
|
38
|
-
"publish_tree mirrors a whole subtree by path and never enumerates an index.",
|
|
39
|
-
)
|
|
40
|
-
end
|
|
36
|
+
raise UsageError.new("entry '#{entry.key}': publish.tree must be a string") unless publish_tree.is_a?(String)
|
|
41
37
|
|
|
42
38
|
used_vars = publish_tree.scan(Template::VAR_RE).flatten
|
|
43
39
|
return if used_vars.empty?
|
|
44
40
|
|
|
45
41
|
raise UsageError.new(
|
|
46
|
-
"entry '#{entry.key}':
|
|
42
|
+
"entry '#{entry.key}': publish.tree names a single directory and takes no template variable(s) " \
|
|
47
43
|
"#{used_vars.map { |v| "{#{v}}" }.join(", ")} — it mirrors the whole subtree to one target dir.",
|
|
48
44
|
)
|
|
49
45
|
end
|
|
@@ -5,26 +5,28 @@ module Textus
|
|
|
5
5
|
# realized as one resolved sum type. Each directory entry resolves, once,
|
|
6
6
|
# to one Publish::* mode that owns its publish algorithm — no nil-cascade,
|
|
7
7
|
# no pairwise exclusivity guards, one shared subtree mirror. ADR 0051
|
|
8
|
-
# removed `publish_each` (both leaf modes);
|
|
8
|
+
# removed `publish_each` (both leaf modes); ADR 0052 folded the two surviving
|
|
9
|
+
# keys into one `publish:` block (`to:` xor `tree:`). The surface is two modes:
|
|
9
10
|
#
|
|
10
|
-
# None — nothing to publish
|
|
11
|
-
# ToPaths —
|
|
12
|
-
# Tree —
|
|
11
|
+
# None — nothing to publish (no publish: block)
|
|
12
|
+
# ToPaths — publish: { to: [...] } — 1 stored file -> N fixed repo paths
|
|
13
|
+
# Tree — publish: { tree: "dir" } — whole entry subtree -> 1 dir, no keys
|
|
13
14
|
module Publish
|
|
14
|
-
# Resolve an entry to its single publish mode.
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# Resolve an entry to its single publish mode. The publish config is the
|
|
16
|
+
# ADR 0052 `publish:` block, sourced into entry.publish_to/publish_tree.
|
|
17
|
+
# Raises one UsageError if both `publish.to` and `publish.tree` are set —
|
|
18
|
+
# the block groups the two but does not make exclusivity structural, so
|
|
19
|
+
# this stays the one enforcement point (ADR 0052 D2).
|
|
18
20
|
def self.resolve(entry)
|
|
19
21
|
reject_removed_publish_each(entry)
|
|
20
22
|
|
|
21
23
|
set = []
|
|
22
|
-
set << "
|
|
23
|
-
set << "
|
|
24
|
+
set << "publish.to" unless Array(entry.publish_to).empty?
|
|
25
|
+
set << "publish.tree" unless entry.publish_tree.nil?
|
|
24
26
|
|
|
25
27
|
if set.length > 1
|
|
26
28
|
raise Textus::UsageError.new(
|
|
27
|
-
"entry '#{entry.key}': #{set.join("
|
|
29
|
+
"entry '#{entry.key}': #{set.join(" and ")} are mutually exclusive — an entry publishes exactly one way",
|
|
28
30
|
)
|
|
29
31
|
end
|
|
30
32
|
|
|
@@ -36,15 +38,15 @@ module Textus
|
|
|
36
38
|
|
|
37
39
|
raise Textus::UsageError.new(
|
|
38
40
|
"entry '#{entry.key}': publish_each was removed in 0.42.0 (ADR 0051) — " \
|
|
39
|
-
"mirror the subtree with
|
|
41
|
+
"mirror the subtree with `publish: { tree: \"...\" }`.",
|
|
40
42
|
)
|
|
41
43
|
end
|
|
42
44
|
private_class_method :reject_removed_publish_each
|
|
43
45
|
|
|
44
46
|
def self.mode_for(entry, key)
|
|
45
47
|
case key
|
|
46
|
-
when "
|
|
47
|
-
when "
|
|
48
|
+
when "publish.to" then ToPaths.new(entry)
|
|
49
|
+
when "publish.tree" then Tree.new(entry)
|
|
48
50
|
else None.new(entry)
|
|
49
51
|
end
|
|
50
52
|
end
|
|
@@ -8,11 +8,13 @@ module Textus
|
|
|
8
8
|
# the scattered pairwise "not-both" guards of the old PublishEach +
|
|
9
9
|
# PublishTree validators. Misuse on a non-nested entry is still caught
|
|
10
10
|
# here from raw, since the typed attrs stub nil on Base. (publish_each was
|
|
11
|
-
# removed in 0.42.0 — ADR 0051;
|
|
11
|
+
# removed in 0.42.0 — ADR 0051; the publish_to/publish_tree keys were folded
|
|
12
|
+
# into the `publish:` block in 0.43.0 — ADR 0052; Schema rejects the retired
|
|
13
|
+
# flat keys at load.)
|
|
12
14
|
module Publish
|
|
13
15
|
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
14
16
|
unless entry.nested?
|
|
15
|
-
raise UsageError.new("entry '#{entry.key}':
|
|
17
|
+
raise UsageError.new("entry '#{entry.key}': publish.tree requires nested: true") if entry.raw.dig("publish", "tree")
|
|
16
18
|
|
|
17
19
|
return
|
|
18
20
|
end
|
|
@@ -51,13 +51,8 @@ module Textus
|
|
|
51
51
|
else
|
|
52
52
|
raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless nested_entry?(entry)
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
path =
|
|
56
|
-
File.join(@data.root, "zones", entry.path, *remaining, index_fn)
|
|
57
|
-
else
|
|
58
|
-
primary_ext = Textus::Entry.for_format(entry.format).extensions.first
|
|
59
|
-
File.join(@data.root, "zones", entry.path, *remaining) + primary_ext
|
|
60
|
-
end
|
|
54
|
+
primary_ext = Textus::Entry.for_format(entry.format).extensions.first
|
|
55
|
+
path = File.join(@data.root, "zones", entry.path, *remaining) + primary_ext
|
|
61
56
|
Resolution.new(entry: entry, path: path, remaining: remaining)
|
|
62
57
|
end
|
|
63
58
|
end
|
|
@@ -68,33 +63,22 @@ module Textus
|
|
|
68
63
|
end
|
|
69
64
|
|
|
70
65
|
def enumerate_nested(entry)
|
|
66
|
+
# publish_tree mirrors opaque payload by path — its files are never
|
|
67
|
+
# enumerated as keys (ADR 0047). Ask the resolved mode, not the path.
|
|
68
|
+
return [] if entry.publish_mode.keyless?
|
|
69
|
+
|
|
71
70
|
base = File.join(@data.root, "zones", entry.path)
|
|
72
71
|
return [] unless File.directory?(base)
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return Dir.glob(File.join(base, nested_glob(entry.format)))
|
|
77
|
-
.filter_map { |path| nested_row_for(entry, base, path) }
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
claimed = []
|
|
81
|
-
Dir.glob(File.join(base, "**", entry_index_filename))
|
|
82
|
-
.sort_by { |path| path.count("/") }
|
|
83
|
-
.filter_map do |path|
|
|
84
|
-
leaf_dir = File.dirname(path)
|
|
85
|
-
next nil if claimed.any? { |d| leaf_dir.start_with?("#{d}/") }
|
|
86
|
-
|
|
87
|
-
claimed << leaf_dir
|
|
88
|
-
nested_row_for(entry, base, path)
|
|
89
|
-
end
|
|
73
|
+
Dir.glob(File.join(base, nested_glob(entry.format)))
|
|
74
|
+
.filter_map { |path| nested_row_for(entry, base, path) }
|
|
90
75
|
end
|
|
91
76
|
|
|
92
77
|
def nested_row_for(entry, base, path)
|
|
93
78
|
rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
94
79
|
return nil if entry.ignored?(rel)
|
|
95
80
|
|
|
96
|
-
|
|
97
|
-
stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
81
|
+
stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
98
82
|
segs = stripped.split("/").reject { |s| s.empty? || s == "." }
|
|
99
83
|
return nil if segs.empty?
|
|
100
84
|
|
|
@@ -24,9 +24,12 @@ module Textus
|
|
|
24
24
|
KIND_REQUIRES_VERB = LANES
|
|
25
25
|
ENTRY_KEYS = %w[
|
|
26
26
|
key path zone kind schema owner nested format
|
|
27
|
-
compute template
|
|
28
|
-
intake events inject_boot
|
|
27
|
+
compute template publish
|
|
28
|
+
intake events inject_boot ignore tracked
|
|
29
29
|
].freeze
|
|
30
|
+
# ADR 0052: the typed publish block — `publish: { to: [...] }` (file
|
|
31
|
+
# fan-out) xor `publish: { tree: "dir" }` (subtree mirror).
|
|
32
|
+
PUBLISH_KEYS = %w[to tree].freeze
|
|
30
33
|
COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
|
|
31
34
|
INTAKE_KEYS = %w[handler config].freeze
|
|
32
35
|
RULE_KEYS = %w[match fetch intake_handler_allowlist guard retention].freeze
|
|
@@ -73,25 +76,62 @@ module Textus
|
|
|
73
76
|
def self.validate_entries!(entries)
|
|
74
77
|
Array(entries).each_with_index do |e, i|
|
|
75
78
|
path = "$.entries[#{i}]"
|
|
76
|
-
|
|
79
|
+
reject_retired_publish_keys!(e, path)
|
|
77
80
|
walk(e, ENTRY_KEYS, path)
|
|
81
|
+
validate_publish_block!(e, path)
|
|
78
82
|
walk(e["compute"], COMPUTE_KEYS, "#{path}.compute") if e["compute"].is_a?(Hash)
|
|
79
83
|
walk(e["intake"], INTAKE_KEYS, "#{path}.intake") if e["intake"].is_a?(Hash)
|
|
80
84
|
end
|
|
81
85
|
end
|
|
82
86
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
# Retired keys are no longer allowed, so `walk` would reject them as merely
|
|
88
|
+
# "unknown"; intercept first with the migration path so a pre-0.43 manifest
|
|
89
|
+
# gets a useful error. `publish_each` was removed (ADR 0051); `publish_to`/
|
|
90
|
+
# `publish_tree` were folded into the `publish:` block (ADR 0052);
|
|
91
|
+
# `index_filename` was removed (ADR 0053).
|
|
92
|
+
def self.reject_retired_publish_keys!(entry, path)
|
|
93
|
+
return unless entry.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
if entry.key?("publish_each")
|
|
96
|
+
raise BadManifest.new(
|
|
97
|
+
"publish_each was removed in 0.42.0 (ADR 0051) at '#{path}' — " \
|
|
98
|
+
"mirror the subtree with `publish: { tree: \"...\" }`.",
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if entry.key?("publish_to")
|
|
103
|
+
raise BadManifest.new(
|
|
104
|
+
"publish_to was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
|
|
105
|
+
"use `publish: { to: [...] }`.",
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if entry.key?("publish_tree")
|
|
110
|
+
raise BadManifest.new(
|
|
111
|
+
"publish_tree was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
|
|
112
|
+
"use `publish: { tree: \"...\" }`.",
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
return unless entry.key?("index_filename")
|
|
88
117
|
|
|
89
118
|
raise BadManifest.new(
|
|
90
|
-
"
|
|
91
|
-
"
|
|
119
|
+
"index_filename was removed in 0.43.0 (ADR 0053) at '#{path}' — a nested entry now enumerates " \
|
|
120
|
+
"each file as a key; to mirror a directory of files to a consumer path use `publish: { tree: \"...\" }`.",
|
|
92
121
|
)
|
|
93
122
|
end
|
|
94
123
|
|
|
124
|
+
# Shape of the ADR 0052 publish block: a Hash whose only keys are to/tree.
|
|
125
|
+
# Exclusivity (both set) and per-mode rules stay in Publish.resolve (ADR 0049).
|
|
126
|
+
def self.validate_publish_block!(entry, path)
|
|
127
|
+
return unless entry.is_a?(Hash) && entry.key?("publish")
|
|
128
|
+
|
|
129
|
+
block = entry["publish"]
|
|
130
|
+
raise BadManifest.new("publish: must be a mapping with `to:` or `tree:` at '#{path}.publish'") unless block.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
walk(block, PUBLISH_KEYS, "#{path}.publish")
|
|
133
|
+
end
|
|
134
|
+
|
|
95
135
|
def self.validate_rules!(rules)
|
|
96
136
|
Array(rules).each_with_index do |r, i|
|
|
97
137
|
path = "$.rules[#{i}]"
|
data/lib/textus/mcp/catalog.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Textus
|
|
|
11
11
|
# Contracts of every MCP-surfaced verb, in Dispatcher order.
|
|
12
12
|
def specs
|
|
13
13
|
Textus::Dispatcher::VERBS.values
|
|
14
|
-
.select { |k|
|
|
14
|
+
.select { |k| mcp_surfaced?(k) }
|
|
15
15
|
.map(&:contract)
|
|
16
16
|
end
|
|
17
17
|
|
|
@@ -25,9 +25,23 @@ module Textus
|
|
|
25
25
|
specs.map { |s| s.verb.to_s }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# MCP-surfaced read verbs, by Dispatcher class namespace — the agent's
|
|
29
|
+
# real read/discovery surface. `boot.agent_quickstart.read_verbs` derives
|
|
30
|
+
# from this so it can never advertise a verb the agent cannot call, nor
|
|
31
|
+
# omit one it can (ADR 0056). Excludes Write/Maintenance.
|
|
32
|
+
def read_verbs
|
|
33
|
+
Textus::Dispatcher::VERBS
|
|
34
|
+
.select { |_verb, klass| mcp_surfaced?(klass) && klass.name.start_with?("Textus::Read::") }
|
|
35
|
+
.keys.map(&:to_s)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def mcp_surfaced?(klass)
|
|
39
|
+
klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
|
|
40
|
+
end
|
|
41
|
+
|
|
28
42
|
def call(name, session:, store:, args:)
|
|
29
43
|
klass = Textus::Dispatcher::VERBS[name.to_sym]
|
|
30
|
-
raise ToolError.new("unknown tool: #{name}") unless klass
|
|
44
|
+
raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
|
|
31
45
|
|
|
32
46
|
spec = klass.contract
|
|
33
47
|
pos, kw = map_args(spec, args || {}, session)
|
data/lib/textus/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: textus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.43.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -271,7 +271,6 @@ files:
|
|
|
271
271
|
- lib/textus/manifest/entry/validators/events.rb
|
|
272
272
|
- lib/textus/manifest/entry/validators/format_matrix.rb
|
|
273
273
|
- lib/textus/manifest/entry/validators/ignore.rb
|
|
274
|
-
- lib/textus/manifest/entry/validators/index_filename.rb
|
|
275
274
|
- lib/textus/manifest/entry/validators/inject_boot.rb
|
|
276
275
|
- lib/textus/manifest/entry/validators/publish.rb
|
|
277
276
|
- lib/textus/manifest/policy.rb
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Manifest
|
|
3
|
-
class Entry
|
|
4
|
-
module Validators
|
|
5
|
-
module IndexFilename
|
|
6
|
-
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
|
-
# Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
|
|
8
|
-
index_filename = entry.nested? ? entry.index_filename : entry.raw["index_filename"]
|
|
9
|
-
return if index_filename.nil?
|
|
10
|
-
|
|
11
|
-
check_shape!(entry, index_filename)
|
|
12
|
-
check_extension!(entry, index_filename)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def self.check_shape!(entry, index_filename)
|
|
16
|
-
raise UsageError.new("entry '#{entry.key}': index_filename requires nested: true") unless entry.nested?
|
|
17
|
-
|
|
18
|
-
unless index_filename.is_a?(String) && !index_filename.empty?
|
|
19
|
-
raise UsageError.new("entry '#{entry.key}': index_filename must be a non-empty string")
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
return unless index_filename.include?("/") || File.basename(index_filename) != index_filename
|
|
23
|
-
|
|
24
|
-
raise UsageError.new("entry '#{entry.key}': index_filename must be a bare basename (no slashes)")
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def self.check_extension!(entry, index_filename)
|
|
28
|
-
ext = File.extname(index_filename)
|
|
29
|
-
inferred = Textus::Entry.infer_from_extension(ext)
|
|
30
|
-
|
|
31
|
-
if inferred.nil?
|
|
32
|
-
raise UsageError.new(
|
|
33
|
-
"entry '#{entry.key}': index_filename #{index_filename.inspect} has unknown extension #{ext.inspect}",
|
|
34
|
-
)
|
|
35
|
-
end
|
|
36
|
-
return if inferred == entry.format
|
|
37
|
-
|
|
38
|
-
raise UsageError.new(
|
|
39
|
-
"entry '#{entry.key}': index_filename extension #{ext.inspect} implies format #{inferred.inspect}, " \
|
|
40
|
-
"but entry format is #{entry.format.inspect}",
|
|
41
|
-
)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|