textus 0.45.1 → 0.47.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 +29 -0
- data/README.md +53 -26
- data/SPEC.md +15 -14
- data/docs/architecture/README.md +6 -25
- data/lib/textus/boot.rb +1 -0
- data/lib/textus/builder/pipeline.rb +11 -42
- data/lib/textus/builder/renderer/markdown.rb +4 -8
- data/lib/textus/cli/verb/build.rb +1 -10
- data/lib/textus/cli/verb/init.rb +3 -1
- data/lib/textus/cli.rb +29 -1
- data/lib/textus/container.rb +3 -15
- data/lib/textus/contract/resources/build_lock.rb +17 -0
- data/lib/textus/dispatcher.rb +1 -0
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +1 -1
- data/lib/textus/domain/policy/predicates/fresh_within.rb +6 -5
- data/lib/textus/envelope/io/writer.rb +34 -0
- data/lib/textus/etag.rb +23 -0
- data/lib/textus/hooks/catalog.rb +1 -0
- data/lib/textus/init/templates/orientation_reducer.rb +17 -0
- data/lib/textus/init.rb +67 -4
- data/lib/textus/layout.rb +8 -0
- data/lib/textus/maintenance/key_delete_prefix.rb +5 -4
- data/lib/textus/maintenance/key_mv_prefix.rb +14 -4
- data/lib/textus/maintenance/migrate.rb +5 -4
- data/lib/textus/maintenance/rule_lint.rb +1 -1
- data/lib/textus/maintenance/zone_mv.rb +5 -4
- data/lib/textus/mcp/server.rb +14 -4
- data/lib/textus/ports/publisher.rb +3 -2
- data/lib/textus/ports/sentinel_store.rb +8 -7
- data/lib/textus/projection.rb +4 -3
- data/lib/textus/read/audit.rb +1 -1
- data/lib/textus/read/blame.rb +1 -1
- data/lib/textus/read/boot.rb +1 -1
- data/lib/textus/read/capabilities.rb +70 -0
- data/lib/textus/read/deps.rb +1 -1
- data/lib/textus/read/doctor.rb +1 -1
- data/lib/textus/read/freshness.rb +1 -1
- data/lib/textus/read/get.rb +1 -1
- data/lib/textus/read/list.rb +1 -1
- data/lib/textus/read/published.rb +1 -1
- data/lib/textus/read/pulse.rb +4 -4
- data/lib/textus/read/rdeps.rb +1 -1
- data/lib/textus/read/rule_explain.rb +1 -1
- data/lib/textus/read/rule_list.rb +1 -1
- data/lib/textus/read/schema_envelope.rb +1 -1
- data/lib/textus/read/uid.rb +1 -1
- data/lib/textus/read/where.rb +1 -1
- data/lib/textus/session.rb +6 -5
- data/lib/textus/store.rb +48 -25
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +1 -1
- data/lib/textus/write/build.rb +19 -7
- data/lib/textus/write/delete.rb +1 -1
- data/lib/textus/write/fetch_all.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +1 -1
- data/lib/textus/write/mv.rb +1 -1
- data/lib/textus/write/propose.rb +1 -1
- data/lib/textus/write/put.rb +1 -1
- data/lib/textus/write/reject.rb +1 -1
- data/lib/textus/write/retention_sweep.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c87fe067b0988d187beac617c4540e1563d0f006a08d6090b2b483c699555d46
|
|
4
|
+
data.tar.gz: 4f0503119ebf3eeff28d7a16aee8dc8c62673e6b41912e85bc3453e596075e65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fde04b0339e798e545c98699462afaf2767a772f1e76811c4f8833b561b69d02b172e4879cf17ffaea1dd51f1930846007abf4523a801ccfb5525c53e1365b7f
|
|
7
|
+
data.tar.gz: 10a4343781cb2251ba8f91121c0a89b1fe211702e7c5057693fce27357eee55c3abe6bfe7a88009ffc89f5fcc49ad6c44fcf258f532f5ac9d69d960857e833c6
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,35 @@ 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.47.0 — 2026-06-04 — Close the agent loop over MCP
|
|
13
|
+
|
|
14
|
+
The edit→accept→rebuild loop now closes over a single MCP transport: `build` is surfaced to MCP, `init --with-agent` scaffolds a connectable agent setup, and connection-lifecycle hardening (whole-contract drift fingerprint + a connect-time event carrying the resolved role) underpins it.
|
|
15
|
+
|
|
16
|
+
### Changed (breaking)
|
|
17
|
+
|
|
18
|
+
- **Drift guard now fingerprints the whole contract.** `Session#manifest_etag` is renamed `contract_etag` and now digests `manifest.yaml` + `hooks/**/*.rb` + `schemas/**/*` ([ADR 0074](docs/architecture/decisions/0074-contract-etag-drift-guard.md)). A mid-session edit to any hook or schema raises `contract_drift` on the next MCP `tools/call`, where previously only a manifest edit did. The composite digest lives as `Etag.for_contract`.
|
|
19
|
+
- **Wire:** the `pulse` envelope key `manifest_etag` is renamed `contract_etag`.
|
|
20
|
+
- **Ruby:** `Textus::Session#manifest_etag` / `Textus::MCP::Session#manifest_etag` is renamed `contract_etag`. Embedders constructing a `Session` must pass `contract_etag:`.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **`build` is surfaced to MCP** ([ADR 0076](docs/architecture/decisions/0076-build-gates-by-capability-actor-surface-to-mcp.md)) — previously CLI-only. `build` is transport-uniform, caller-agnostic, and self-elevating: it always runs as the manifest's `build`-capable actor (not the caller) and grants no authority over content, since it only recomputes the deterministic, content-addressed projection of already-accepted canon. Actor-resolution and the `BuildLock` moved out of the CLI verb into the shared `Write::Build` use-case (an `around :build_lock` resource), so single-writer serialization now spans **every** transport — closing a latent gap where the Ruby API path skipped the lock. The MCP catalog derives the new tool; `boot` auto-advertises it.
|
|
25
|
+
- **`textus init --with-agent`** ([ADR 0077](docs/architecture/decisions/0077-init-with-agent-profile.md)) — an opt-in profile that scaffolds a connectable agent setup on top of the neutral store: `knowledge.project` + `knowledge.runbooks` entries (with their schemas), an `artifacts.orientation` derived entry that projects them to `CLAUDE.md`/`AGENTS.md`, and a write-once starter `.mcp.json` at the project root. The default `init` (no flag) is unchanged and stays vendor-neutral; under the flag, `init` writes exactly one file outside `.textus/` (`.mcp.json`, never clobbered if present). Paired with `build` over MCP, an agent can edit `knowledge.*` and rebuild its own orientation without leaving the conversation.
|
|
26
|
+
- **`:session_opened` hook event** ([ADR 0075](docs/architecture/decisions/0075-session-opened-connect-event.md)) — fires once per MCP connection at `initialize` with `ctx:, role:, cursor:` (the resolved connection role). Use it for connect-time, role-keyed behavior (session logging, context priming). Distinct from `:store_loaded` (process-time, default role).
|
|
27
|
+
|
|
28
|
+
## 0.46.0 — 2026-06-03 — Container is the single source of truth
|
|
29
|
+
|
|
30
|
+
No `textus/3` wire-format change. Internal refactor of the composition root. The 7-field capability set (`manifest, file_store, schemas, root, audit_log, events, rpc`) was previously spelled out four times — `Container`'s `Data.define`, `Store`'s ivar assignments, `Store`'s `attr_reader`s, and `Container.from_store`. It now lives in exactly one place.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **`Store` builds its `Container` once and derives its readers from it.** `Store#initialize` constructs the `Container` directly (`build_container`); the public accessors (`store.manifest`, `store.root`, …) are generated from `Container.members`, so adding a capability to the `Data.define` auto-exposes it on `Store`. Hook wiring is extracted into `bootstrap_hooks`.
|
|
35
|
+
- **`Store.discover` decomposed.** The upward directory walk is extracted into `ascend_for_store`, and the duplicated `.textus`/`manifest.yaml` existence check into a shared `store_dir?` predicate.
|
|
36
|
+
|
|
37
|
+
### Breaking (pre-1.0)
|
|
38
|
+
|
|
39
|
+
- **`Container.from_store` is removed.** It built a fresh `Container` by copying the seven accessors off a `Store`. Use `store.container` instead (built once, memoized). Specs that swapped the event bus post-construction via `store.instance_variable_set(:@events, …)` now inject explicitly through the immutable `Container`'s `#with` (e.g. `container.with(events: probe)`). Retires the `from_store` idiom described in [ADR 0016](docs/architecture/decisions/0016-application-ports-value.md) and [ADR 0020](docs/architecture/decisions/0020-capability-records.md).
|
|
40
|
+
|
|
12
41
|
## 0.45.1 — 2026-06-03 — Single-path lifecycle: kill the last dual-paths ([ADR 0069](docs/architecture/decisions/0069-single-path-lifecycle.md))
|
|
13
42
|
|
|
14
43
|
No `textus/3` wire-format change. Finishes the 0.45.0 lifecycle (ADRs 0066–0068): the request path was single-path in shape but still carried four residual dual-paths. This removes all four so the lifecycle — `normalize → bind (always validate) → dispatch (+around) → view (self-shaping)` — is single-path in fact on every surface. Three breaking (pre-1.0) changes, all accepted.
|
data/README.md
CHANGED
|
@@ -72,6 +72,8 @@ TRANSIENT │ feeds │ proposals (queue) │
|
|
|
72
72
|
raw material ──── propose ────► a human accept lifts it to canon
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
*(The fifth lane, `artifacts`, isn't on this grid — it's a derived **output**, computed from the lanes rather than an input climbing toward trust.)*
|
|
76
|
+
|
|
75
77
|
Without coordination, they overwrite each other and nothing remembers why. textus gives each actor a **lane** — called a **zone** in the manifest and CLI, the term used everywhere technical from here on — routes everything they can't write directly through a **proposals queue**, and writes every successful change to an **append-only audit log**. The lanes are enforced at the protocol level, not by convention.
|
|
76
78
|
|
|
77
79
|
```
|
|
@@ -91,9 +93,16 @@ That's the load-bearing claim: **coordination is a protocol invariant, not a lib
|
|
|
91
93
|
```sh
|
|
92
94
|
gem install textus
|
|
93
95
|
textus init # creates .textus/ with zones + schemas
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
|
|
97
|
+
# an agent proposes a change — it targets a knowledge entry, but lands in proposals/
|
|
98
|
+
textus propose notes.oncall --as=agent --stdin <<'JSON'
|
|
99
|
+
{
|
|
100
|
+
"_meta": { "name": "oncall",
|
|
101
|
+
"proposal": { "target_key": "knowledge.notes.oncall", "action": "put" } },
|
|
102
|
+
"body": "Patrick on call.\n"
|
|
103
|
+
}
|
|
104
|
+
JSON
|
|
105
|
+
|
|
97
106
|
# you accept it — textus promotes to knowledge/ and audits the move
|
|
98
107
|
textus accept proposals.notes.oncall --as=human
|
|
99
108
|
```
|
|
@@ -130,7 +139,7 @@ bundle exec exe/textus --help
|
|
|
130
139
|
|
|
131
140
|
## What `textus init` gives you
|
|
132
141
|
|
|
133
|
-
You get `.textus/` with all five zone directories, baseline schemas,
|
|
142
|
+
You get `.textus/` with all five zone directories, baseline schemas, a starter manifest, and a gitignored `.run/` for disposable runtime state (the audit log, per-role cursors, fetch/build locks). Roles declare capabilities; each zone declares a `kind:`, and write authority is derived from the role's capabilities crossed with the zone's kind:
|
|
134
143
|
|
|
135
144
|
```yaml
|
|
136
145
|
roles:
|
|
@@ -148,18 +157,22 @@ zones:
|
|
|
148
157
|
|
|
149
158
|
```
|
|
150
159
|
.textus/
|
|
151
|
-
manifest.yaml
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
manifest.yaml # role capabilities + zone kinds + key-to-path mapping
|
|
161
|
+
schemas/ # YAML field shapes per entry family
|
|
162
|
+
templates/ # mustache templates for derived entries
|
|
163
|
+
hooks/ # one .rb per hook
|
|
164
|
+
.gitignore # generated — ignores .run/ and any tracked:false entries
|
|
165
|
+
zones/ # one dir per zone; kinds + capabilities are in the manifest above
|
|
166
|
+
knowledge/ # e.g. identity (knowledge.identity.*), voice, decisions, notes
|
|
167
|
+
notebook/
|
|
168
|
+
feeds/
|
|
169
|
+
proposals/
|
|
170
|
+
artifacts/
|
|
171
|
+
.run/ # disposable runtime state — gitignored, safe to delete (ADR 0038)
|
|
172
|
+
audit/audit.log # append-only NDJSON event ledger, every write (rotates at ~50 MB)
|
|
173
|
+
state/cursor.<role> # per-role pulse cursor — where `pulse --since` resumes
|
|
174
|
+
locks/ build.lock # per-key fetch locks + the build mutex
|
|
175
|
+
sentinels/ # publish bookkeeping (target sha) — regenerated on build (ADR 0070)
|
|
163
176
|
```
|
|
164
177
|
|
|
165
178
|
Manifest `path:` fields are relative to `.textus/zones/`. So `knowledge.notes.org.jane` lives at `.textus/zones/knowledge/notes/org/jane.md`.
|
|
@@ -176,7 +189,7 @@ textus rule list # show every rule block
|
|
|
176
189
|
textus audit --limit=20 # query the audit log
|
|
177
190
|
```
|
|
178
191
|
|
|
179
|
-
(All verbs return JSON envelopes
|
|
192
|
+
(All verbs return JSON envelopes; `--output=json` is the default and the only format in v1.)
|
|
180
193
|
|
|
181
194
|
For a worked store — knowledge entries, a staged proposal, schemas, a template, and a build that publishes `CLAUDE.md` / `AGENTS.md` — see [`examples/project/`](examples/project/).
|
|
182
195
|
|
|
@@ -190,7 +203,7 @@ For a worked store — knowledge entries, a staged proposal, schemas, a template
|
|
|
190
203
|
|
|
191
204
|
## CLI and zones
|
|
192
205
|
|
|
193
|
-
|
|
206
|
+
Every command operates on one store, located in this order: `--root <path>` flag → **`TEXTUS_ROOT`** env → walk up from the working directory for a `.textus/` ([SPEC §3.1](SPEC.md)). Write verbs require `--as=<role>`, resolved as: `--as` flag → **`TEXTUS_ROLE`** env → `.textus/role` file → default `human` ([SPEC §5.1](SPEC.md)). Default roles: `human`, `agent`, `automation` (rename or add your own in the manifest's `roles:` block). All verbs accept `--output=json` and return the envelope defined in [SPEC §8](SPEC.md).
|
|
194
207
|
|
|
195
208
|
- Full verb table — read, write, health, scaffolding — is in [SPEC §9](SPEC.md).
|
|
196
209
|
- Zone semantics and the capability × zone-kind mapping live in [SPEC §5](SPEC.md), with the reference in [`docs/reference/zones.md`](docs/reference/zones.md).
|
|
@@ -203,17 +216,31 @@ Derived entries declare `compute: { kind: projection, select: ..., pluck: ..., s
|
|
|
203
216
|
|
|
204
217
|
For externally-generated entries, declare `compute: { kind: external, sources: [...] }` — textus tracks the declared sources for staleness; the build automation produces the file.
|
|
205
218
|
|
|
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
|
|
219
|
+
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/.run/sentinels/` (git-ignored runtime state, regenerated on build — ADR 0070). See SPEC §5.2, §5.3, §5.12.
|
|
207
220
|
|
|
208
221
|
## Extension points
|
|
209
222
|
|
|
210
|
-
textus exposes a hook DSL. Drop `.rb` files into `.textus/hooks/` (subdirectories are fine; files load alphabetically by full path).
|
|
223
|
+
textus exposes a hook DSL. Drop `.rb` files into `.textus/hooks/` (subdirectories are fine; files load alphabetically by full path). There are two kinds:
|
|
224
|
+
|
|
225
|
+
**RPC hooks** — one handler, the framework uses what you return:
|
|
226
|
+
|
|
227
|
+
| Event | Fires when | You return |
|
|
228
|
+
|---|---|---|
|
|
229
|
+
| `:resolve_intake` | a fetch needs bytes | `{_meta:, body:}` |
|
|
230
|
+
| `:transform_rows` | a projection builds | the reshaped rows |
|
|
231
|
+
| `:validate` | `textus doctor` runs | doctor issues (or none) |
|
|
232
|
+
|
|
233
|
+
**Pub-sub hooks** — 0..N handlers, fire-and-react (no return value):
|
|
211
234
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
235
|
+
| Event(s) | Fires when |
|
|
236
|
+
|---|---|
|
|
237
|
+
| `:entry_put` · `:entry_deleted` · `:entry_renamed` | a write lands |
|
|
238
|
+
| `:entry_fetched` | a fetch-driven write lands |
|
|
239
|
+
| `:build_completed` | a derived entry materializes |
|
|
240
|
+
| `:file_published` | a derived file is copied to its target |
|
|
241
|
+
| `:proposal_accepted` · `:proposal_rejected` | a proposal is resolved |
|
|
242
|
+
| `:fetch_started` · `:fetch_failed` · `:fetch_backgrounded` | background-fetch lifecycle |
|
|
243
|
+
| `:store_loaded` | the store finishes loading |
|
|
217
244
|
|
|
218
245
|
```ruby
|
|
219
246
|
# Inside .textus/hooks/local_file.rb
|
|
@@ -273,4 +300,4 @@ Lefthook hooks (`brew bundle install` then `lefthook install`) run rubocop on `p
|
|
|
273
300
|
|
|
274
301
|
## License
|
|
275
302
|
|
|
276
|
-
[MIT](LICENSE)
|
|
303
|
+
[MIT](LICENSE)
|
data/SPEC.md
CHANGED
|
@@ -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 `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"`. |
|
|
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/.run/sentinels/<target-rel-path>.textus-managed.json` (git-ignored runtime state) 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
|
|
@@ -135,7 +135,7 @@ The root is `.textus/` at the project working directory. A typical tree:
|
|
|
135
135
|
schemas/ # internal: YAML schema files
|
|
136
136
|
templates/ # internal: Mustache templates referenced by derived entries
|
|
137
137
|
hooks/ # internal: one Ruby file per hook
|
|
138
|
-
sentinels/
|
|
138
|
+
.run/sentinels/ # runtime (git-ignored): byte-copied publish bookkeeping, regenerated on build (see §5.3)
|
|
139
139
|
zones/ # ALL user content lives here
|
|
140
140
|
knowledge/ # zone: knowledge (kind: canon — author-holders write; knowledge.identity.* is the identity convention)
|
|
141
141
|
notebook/ # zone: notebook (kind: workspace — keep-holders write; agent's own durable lane)
|
|
@@ -144,7 +144,7 @@ The root is `.textus/` at the project working directory. A typical tree:
|
|
|
144
144
|
artifacts/ # zone: artifacts (kind: derived — build-holders write)
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
-
Textus internals (`manifest.yaml`, `
|
|
147
|
+
Textus internals (`manifest.yaml`, `schemas/`, `templates/`, `hooks/`) live directly under `.textus/`; disposable runtime state (the audit log, publish `sentinels/`, fetch/build locks, pulse cursors) lives under `.textus/.run/` (git-ignored, ADR 0038/0070). **All user content lives under `.textus/zones/`.** Manifest `path:` fields are relative to `.textus/zones/` — they do **not** include the `zones/` prefix. Implementations MUST prepend `zones/` to every `path:` when resolving a key to a filesystem location.
|
|
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
|
|
|
@@ -450,9 +450,9 @@ publish:
|
|
|
450
450
|
|
|
451
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.
|
|
452
452
|
|
|
453
|
-
A sentinel is written for each published file at `<store_root
|
|
453
|
+
A sentinel is written for each published file at `<store_root>/.run/sentinels/<target-relative-to-repo>.textus-managed.json` (git-ignored runtime state — ADR 0070), recording `source`, `target`, the target's sha256, and `mode: "copy"`. Sentinels live under the store's runtime tree rather than beside the consumer file so target directories stay clean, and are regenerated by the next build (via content-identical adoption) rather than committed. 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.
|
|
454
454
|
|
|
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
|
|
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
457
|
### 5.4 Intake (declared, fetched via registered intake handler)
|
|
458
458
|
|
|
@@ -653,6 +653,7 @@ end
|
|
|
653
653
|
| `:entry_renamed` | pubsub | ctx:, key:, from_key:, to_key:, envelope: | (discarded) | logged |
|
|
654
654
|
| `:proposal_rejected` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
|
|
655
655
|
| `:store_loaded` | pubsub | ctx: | (discarded) | logged |
|
|
656
|
+
| `:session_opened` | pubsub | ctx:, role:, cursor: | (discarded) | logged |
|
|
656
657
|
| `:fetch_started` | pubsub | ctx:, key:, mode: | (discarded) | logged |
|
|
657
658
|
| `:fetch_failed` | pubsub | ctx:, key:, error_class:, error_message: | (discarded) | logged |
|
|
658
659
|
| `:fetch_backgrounded` | pubsub | ctx:, key:, started_at:, budget_ms: | (discarded) | logged |
|
|
@@ -672,7 +673,7 @@ The three `:fetch_*` lifecycle events report the progress and failures of backgr
|
|
|
672
673
|
|
|
673
674
|
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).
|
|
674
675
|
|
|
675
|
-
The primary entity is always `key:` (for `:proposal_accepted`, `key:` is the pending key being accepted and `target_key:` is the destination). For `:entry_renamed`, `key:` is present and equals `to_key:` — it is the entry's post-move home, present so `keys:` glob filters route correctly; `from_key:` is the prior key. For `:proposal_rejected`, `key:` is the pending key being rejected. For `:store_loaded`, no key — the event observes store readiness, not an entry.
|
|
676
|
+
The primary entity is always `key:` (for `:proposal_accepted`, `key:` is the pending key being accepted and `target_key:` is the destination). For `:entry_renamed`, `key:` is present and equals `to_key:` — it is the entry's post-move home, present so `keys:` glob filters route correctly; `from_key:` is the prior key. For `:proposal_rejected`, `key:` is the pending key being rejected. For `:store_loaded`, no key — the event observes store readiness, not an entry. For `:session_opened`, no key — it fires once per MCP connection at `initialize` with the connection's resolved `role:` and boot `cursor:` (ADR 0075); distinct from `:store_loaded`, which fires once per process at `Store#initialize` under the default role.
|
|
676
677
|
|
|
677
678
|
**RPC mode** — exactly one handler per (event, name). The manifest references the handler by name (`intake.handler: NAME`, `compute.transform: NAME`). Failure or timeout aborts the calling operation.
|
|
678
679
|
|
|
@@ -735,10 +736,10 @@ An entry's `format:` selects a storage strategy. All strategies expose the same
|
|
|
735
736
|
**`_meta` convention.** Derived structured entries (json, yaml) embed a `_meta` hash as the first top-level key. Builder-injected keys appear in a fixed order for etag stability:
|
|
736
737
|
|
|
737
738
|
```
|
|
738
|
-
|
|
739
|
+
from, template, transform
|
|
739
740
|
```
|
|
740
741
|
|
|
741
|
-
Keys with `nil` values are omitted. User-shaped content (or the reducer's hash) follows `_meta`. The etag (§10) is the sha256 of the on-disk bytes regardless of format; key ordering MUST therefore be deterministic, which Ruby's `Hash` and `JSON.generate` / `YAML.dump` honor via insertion order.
|
|
742
|
+
Keys with `nil` values are omitted. The builder injects only **deterministic** provenance: it does **not** stamp a `generated_at` build timestamp into the artifact (ADR 0070). A built artifact is content-addressed — rebuilding unchanged sources reproduces it byte-for-byte, so a rebuild is a no-op and a `git` revert never drifts. (The `generated.at` of §5.2 is a separate convention written by *external* build tools, not by textus's own builder.) User-shaped content (or the reducer's hash) follows `_meta`. The etag (§10) is the sha256 of the on-disk bytes regardless of format; key ordering MUST therefore be deterministic, which Ruby's `Hash` and `JSON.generate` / `YAML.dump` honor via insertion order.
|
|
742
743
|
|
|
743
744
|
## 6. Schemas
|
|
744
745
|
|
|
@@ -899,7 +900,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
899
900
|
{
|
|
900
901
|
"agent_quickstart": {
|
|
901
902
|
"read_verbs": ["get", "list", "pulse", "schema_show", "boot", "rule_explain", "where", "deps", "rdeps"],
|
|
902
|
-
"write_verbs": ["delete", "fetch", "fetch_all", "mv", "propose", "put"],
|
|
903
|
+
"write_verbs": ["accept", "delete", "fetch", "fetch_all", "mv", "propose", "put", "reject"],
|
|
903
904
|
"writable_zones": ["proposals"],
|
|
904
905
|
"propose_zone": "proposals",
|
|
905
906
|
"latest_seq": 1842
|
|
@@ -909,7 +910,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
909
910
|
|
|
910
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 freshness/guard policy, and the graph reads `where`/`deps`/`rdeps`, ADR 0060) and never the CLI-only `audit`/`freshness`/`doctor` (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).
|
|
911
912
|
|
|
912
|
-
The agent's MCP write surface includes the single-key `delete` and `mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment)
|
|
913
|
+
The agent's MCP write surface includes the single-key `delete` and `mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment). 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 `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.
|
|
913
914
|
|
|
914
915
|
`latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
|
|
915
916
|
|
|
@@ -922,13 +923,13 @@ The agent's MCP write surface includes the single-key `delete` and `mv` tools al
|
|
|
922
923
|
"stale": [ "artifacts.marketplace" ],
|
|
923
924
|
"pending_review": [ "proposals.proposal.123" ],
|
|
924
925
|
"doctor": { "ok": true, "warn": 0, "fail": 0 },
|
|
925
|
-
"
|
|
926
|
+
"contract_etag": "sha256:1f3a…",
|
|
926
927
|
"next_due_at": "2026-06-01T09:00:00Z",
|
|
927
928
|
"hook_errors": [ { "seq": 1844, "event": "after_put", "hook": "notify", "key": "knowledge.notes.x", "error_class": "Timeout::Error", "error_message": "…", "at": "..." } ]
|
|
928
929
|
}
|
|
929
930
|
```
|
|
930
931
|
|
|
931
|
-
`cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `stale` is sourced from `freshness`. `pending_review` lists all keys in the queue zone. `doctor` is an `{ok, warn, fail}` count summary. `
|
|
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 sourced from `freshness`. `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 freshness 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`.
|
|
932
933
|
|
|
933
934
|
**`put` input** (read from stdin when `--stdin` is given):
|
|
934
935
|
|
|
@@ -1010,13 +1011,13 @@ Given the `person` schema and a `put` whose frontmatter omits `relationship`, th
|
|
|
1010
1011
|
Given a manifest entry `intake.notes` matched by a `rules: [{ match: intake.notes, fetch: { ttl: 1h } }]` block and an envelope on disk whose `_meta.last_fetched_at` is older than `now - ttl`, `textus freshness --output=json` includes a row for `intake.notes` with `status: "stale"`. Calling `textus freshness` does NOT trigger a fetch.
|
|
1011
1012
|
|
|
1012
1013
|
**Fixture E — Projection build:**
|
|
1013
|
-
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
|
|
1014
|
+
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.
|
|
1014
1015
|
|
|
1015
1016
|
**Fixture F — Mustache render:**
|
|
1016
1017
|
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).
|
|
1017
1018
|
|
|
1018
1019
|
**Fixture G — Copy publish:**
|
|
1019
|
-
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.
|
|
1020
|
+
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.
|
|
1020
1021
|
|
|
1021
1022
|
**Fixture H — Audit log format:**
|
|
1022
1023
|
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
|
@@ -237,7 +237,7 @@ soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Store ──▶ me
|
|
|
237
237
|
Two transports, one façade:
|
|
238
238
|
|
|
239
239
|
- **CLI** — human/script surface. `textus boot`, `textus pulse --since=N`, `textus get/put/...`.
|
|
240
|
-
- **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. Tools are auto-derived from the manifest. Session state (cursor, role,
|
|
240
|
+
- **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. Tools are auto-derived from the manifest. Session state (cursor, role, contract_etag) is server-side.
|
|
241
241
|
|
|
242
242
|
Both transports call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`). No duplicate logic.
|
|
243
243
|
|
|
@@ -245,31 +245,12 @@ The agent loop (cadence guide in [`agents-mcp.md`](../how-to/agents-mcp.md)):
|
|
|
245
245
|
|
|
246
246
|
1. **Session start:** `boot()` → contract envelope (zones, entries, schemas, write_flows, agent_quickstart with `latest_seq`).
|
|
247
247
|
2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor}`.
|
|
248
|
-
3. **On demand:** `get`, `put`, `propose`, `fetch`, `
|
|
248
|
+
3. **On demand:** `get`, `put`, `propose`, `fetch`, `schema_show`, `rule_explain`.
|
|
249
249
|
|
|
250
|
-
|
|
250
|
+
Contract drift surfaces as `ContractDrift` (contract_etag mismatch — a change to the manifest, hooks, or schemas; ADR 0074); audit cursor falls off the keep window as `CursorExpired`. Both signal "call `boot` again."
|
|
251
251
|
|
|
252
252
|
## Hooks event catalog
|
|
253
253
|
|
|
254
|
-
`Hooks::Signature` is the single home of callable keyword-introspection — both `EventBus` (pub-sub dispatch) and `RpcRegistry` (RPC dispatch) delegate to it for `accepts_keyrest?`, `declared_keys`, `missing`, and `filter` rather than each maintaining a hand-rolled copy (ADR 0027).
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
- `resolve_intake(caps:, config:, args:)` — intake fetch handler.
|
|
258
|
-
- `transform_rows(caps:, rows:, config:)` — row transform for intakes.
|
|
259
|
-
- `validate(caps:)` — custom doctor validator.
|
|
260
|
-
|
|
261
|
-
Pub-sub (0..N handlers, declare `ctx:`):
|
|
262
|
-
- `entry_put(ctx:, key:, envelope:)`
|
|
263
|
-
- `entry_deleted(ctx:, key:)`
|
|
264
|
-
- `entry_fetched(ctx:, key:, envelope:, change:)`
|
|
265
|
-
- `entry_renamed(ctx:, key:, from_key:, to_key:, envelope:)`
|
|
266
|
-
- `build_completed(ctx:, key:, envelope:, sources:)`
|
|
267
|
-
- `proposal_accepted(ctx:, key:, target_key:)`
|
|
268
|
-
- `proposal_rejected(ctx:, key:, target_key:)`
|
|
269
|
-
- `file_published(ctx:, key:, envelope:, source:, target:)`
|
|
270
|
-
- `store_loaded(ctx:)`
|
|
271
|
-
- `fetch_started(ctx:, key:, mode:)`
|
|
272
|
-
- `fetch_failed(ctx:, key:, error_class:, error_message:)`
|
|
273
|
-
- `fetch_backgrounded(ctx:, key:, started_at:, budget_ms:)`
|
|
274
|
-
|
|
275
|
-
Authoritative source: `lib/textus/hooks/catalog.rb` (`Catalog::RPC` and `Catalog::PUBSUB`).
|
|
254
|
+
`Hooks::Signature` is the single home of callable keyword-introspection — both `EventBus` (pub-sub dispatch) and `RpcRegistry` (RPC dispatch) delegate to it for `accepts_keyrest?`, `declared_keys`, `missing`, and `filter` rather than each maintaining a hand-rolled copy (ADR 0027). RPC handlers declare `caps:` (single handler); pub-sub handlers declare `ctx:` (0..N handlers).
|
|
255
|
+
|
|
256
|
+
The event names, payloads, and per-verb firing order are documented once in [`reference/events.md`](../reference/events.md) (the friendly SSoT); the authoritative source is `lib/textus/hooks/catalog.rb` (`Catalog::RPC` and `Catalog::PUBSUB`).
|
data/lib/textus/boot.rb
CHANGED
|
@@ -95,6 +95,7 @@ module Textus
|
|
|
95
95
|
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
96
96
|
{ "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
97
97
|
{ "name" => "pulse" },
|
|
98
|
+
{ "name" => "capabilities" },
|
|
98
99
|
].freeze
|
|
99
100
|
|
|
100
101
|
# Build the CLI verb catalog by deriving each summary from the corresponding
|
|
@@ -5,8 +5,12 @@ module Textus
|
|
|
5
5
|
module Builder
|
|
6
6
|
module InjectMeta
|
|
7
7
|
# Returns a new hash with _meta as the first key, per SPEC §6 ordering.
|
|
8
|
+
# Carries only deterministic provenance (`from`/`reduce`/`template`) — the
|
|
9
|
+
# volatile `generated_at` is deliberately NOT stamped, so the built
|
|
10
|
+
# artifact is content-addressed and a rebuild is a byte-for-byte no-op
|
|
11
|
+
# (ADR 0070). Build time lives out of the tracked artifact.
|
|
8
12
|
def self.call(content_hash, mentry)
|
|
9
|
-
meta = {
|
|
13
|
+
meta = {}
|
|
10
14
|
if mentry.is_a?(Textus::Manifest::Entry::Derived)
|
|
11
15
|
src = mentry.source
|
|
12
16
|
if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
|
|
@@ -23,35 +27,6 @@ module Textus
|
|
|
23
27
|
end
|
|
24
28
|
end
|
|
25
29
|
|
|
26
|
-
# Replaces the freshly-stamped timestamp inside `new_bytes` with the
|
|
27
|
-
# timestamp pulled from `old_bytes` (same format). Returns the rewritten
|
|
28
|
-
# bytes, or nil if either side lacks a parseable timestamp.
|
|
29
|
-
module IdempotentWrite
|
|
30
|
-
def self.rewrite_with_prior_timestamp(new_bytes:, old_bytes:, format:)
|
|
31
|
-
prior = extract_timestamp(old_bytes, format)
|
|
32
|
-
fresh = extract_timestamp(new_bytes, format)
|
|
33
|
-
return nil unless prior && fresh
|
|
34
|
-
return new_bytes if prior == fresh
|
|
35
|
-
|
|
36
|
-
new_bytes.sub(fresh, prior)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def self.extract_timestamp(bytes, format)
|
|
40
|
-
case format
|
|
41
|
-
when "markdown"
|
|
42
|
-
parsed = Entry.for_format("markdown").parse(bytes)
|
|
43
|
-
parsed.dig("_meta", "generated", "at")
|
|
44
|
-
when "json", "yaml"
|
|
45
|
-
parsed = Entry.for_format(format).parse(bytes)
|
|
46
|
-
parsed.dig("_meta", "generated_at")
|
|
47
|
-
else # rubocop:disable Style/EmptyElse
|
|
48
|
-
nil
|
|
49
|
-
end
|
|
50
|
-
rescue Textus::BadFrontmatter
|
|
51
|
-
nil
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
30
|
module Pipeline
|
|
56
31
|
Deps = Data.define(
|
|
57
32
|
:manifest, :reader, :lister, :rpc, :template_loader, :transform_context, :inject_boot
|
|
@@ -95,18 +70,12 @@ module Textus
|
|
|
95
70
|
target_path
|
|
96
71
|
end
|
|
97
72
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
rewritten = IdempotentWrite.rewrite_with_prior_timestamp(
|
|
105
|
-
new_bytes: bytes, old_bytes: old_bytes, format: format,
|
|
106
|
-
)
|
|
107
|
-
return if rewritten && rewritten == old_bytes
|
|
108
|
-
end
|
|
109
|
-
end
|
|
73
|
+
# Built artifacts are content-addressed (no volatile timestamp, ADR 0070),
|
|
74
|
+
# so identity is plain byte-equality: skip the write when nothing changed.
|
|
75
|
+
# `format` is retained for signature stability across renderers.
|
|
76
|
+
def self.write_if_changed(target_path, bytes, _format)
|
|
77
|
+
return if File.exist?(target_path) && File.binread(target_path) == bytes
|
|
78
|
+
|
|
110
79
|
File.binwrite(target_path, bytes)
|
|
111
80
|
end
|
|
112
81
|
end
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
require "time"
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
2
|
module Builder
|
|
5
3
|
class Renderer
|
|
@@ -14,12 +12,10 @@ module Textus
|
|
|
14
12
|
else
|
|
15
13
|
[]
|
|
16
14
|
end
|
|
17
|
-
frontmatter
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
},
|
|
22
|
-
}
|
|
15
|
+
# Deterministic frontmatter only — `from` (the source keys), never a
|
|
16
|
+
# volatile `generated.at` (ADR 0070): the artifact is content-addressed
|
|
17
|
+
# so a rebuild is a byte-for-byte no-op and a revert never drifts.
|
|
18
|
+
frontmatter = { "generated" => { "from" => from } }
|
|
23
19
|
Entry.for_format("markdown").serialize(meta: frontmatter, body: body)
|
|
24
20
|
end
|
|
25
21
|
end
|
|
@@ -7,16 +7,7 @@ module Textus
|
|
|
7
7
|
option :prefix, "--prefix=K"
|
|
8
8
|
|
|
9
9
|
def invoke(store)
|
|
10
|
-
|
|
11
|
-
raise UsageError.new(
|
|
12
|
-
"no role holds the 'build' capability",
|
|
13
|
-
hint: "declare a role with `can: [build]` in .textus/manifest.yaml",
|
|
14
|
-
)
|
|
15
|
-
Textus::Ports::BuildLock.with(root: store.root) do
|
|
16
|
-
ops = store.as(role)
|
|
17
|
-
result = ops.build(prefix: prefix)
|
|
18
|
-
emit(result)
|
|
19
|
-
end
|
|
10
|
+
emit(store.as(resolved_role(store)).build(prefix: prefix))
|
|
20
11
|
end
|
|
21
12
|
end
|
|
22
13
|
end
|
data/lib/textus/cli/verb/init.rb
CHANGED
|
@@ -4,11 +4,13 @@ module Textus
|
|
|
4
4
|
class Init < Verb
|
|
5
5
|
command_name "init"
|
|
6
6
|
|
|
7
|
+
option :with_agent, "--with-agent"
|
|
8
|
+
|
|
7
9
|
def self.needs_store? = false
|
|
8
10
|
|
|
9
11
|
def call(_store)
|
|
10
12
|
target = File.join(@cwd, ".textus")
|
|
11
|
-
emit(Textus::Init.run(target))
|
|
13
|
+
emit(Textus::Init.run(target, with_agent: !!with_agent))
|
|
12
14
|
end
|
|
13
15
|
end
|
|
14
16
|
end
|
data/lib/textus/cli.rb
CHANGED
|
@@ -33,13 +33,21 @@ module Textus
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def run(argv)
|
|
36
|
+
# `--root` is a global, position-agnostic option: pull it out of argv
|
|
37
|
+
# wherever it appears so it works uniformly before OR after any verb or
|
|
38
|
+
# group (e.g. both `textus --root=X hook list` and
|
|
39
|
+
# `textus hook list --root=X`). Without this, `order!` below only sees
|
|
40
|
+
# options before the first verb token, so a trailing `--root` reached the
|
|
41
|
+
# verb's own parser and raised InvalidOption (#161 F5). TEXTUS_ROOT already
|
|
42
|
+
# works everywhere via Store.discover, so this brings the flag to parity.
|
|
43
|
+
@root_arg = extract_root!(argv)
|
|
44
|
+
|
|
36
45
|
# Define --version/--help ourselves so OptionParser doesn't intercept them
|
|
37
46
|
# with its built-in handlers (which print "version unknown" and a bare usage
|
|
38
47
|
# line, then exit before we ever reach the verb dispatch below).
|
|
39
48
|
show_version = false
|
|
40
49
|
show_help = false
|
|
41
50
|
OptionParser.new do |o|
|
|
42
|
-
o.on("--root=PATH") { |v| @root_arg = v }
|
|
43
51
|
o.on("--version", "-v") { show_version = true }
|
|
44
52
|
o.on("--help", "-h") { show_help = true }
|
|
45
53
|
end.order!(argv)
|
|
@@ -58,6 +66,26 @@ module Textus
|
|
|
58
66
|
|
|
59
67
|
private
|
|
60
68
|
|
|
69
|
+
# Remove the first `--root=PATH` or `--root PATH` token from argv (anywhere)
|
|
70
|
+
# and return its value, or nil if absent. Mutates argv in place.
|
|
71
|
+
def extract_root!(argv)
|
|
72
|
+
i = argv.index { |a| a == "--root" || a.start_with?("--root=") }
|
|
73
|
+
return nil unless i
|
|
74
|
+
|
|
75
|
+
tok = argv[i]
|
|
76
|
+
if tok.start_with?("--root=")
|
|
77
|
+
argv.delete_at(i)
|
|
78
|
+
tok.delete_prefix("--root=")
|
|
79
|
+
else
|
|
80
|
+
val = argv[i + 1]
|
|
81
|
+
raise UsageError.new("--root requires a PATH") if val.nil? || val.start_with?("-")
|
|
82
|
+
|
|
83
|
+
argv.delete_at(i + 1)
|
|
84
|
+
argv.delete_at(i)
|
|
85
|
+
val
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
61
89
|
def coerce_exit_code(value)
|
|
62
90
|
case value
|
|
63
91
|
when Integer then value
|
data/lib/textus/container.rb
CHANGED
|
@@ -1,22 +1,10 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
# Single capability record handed to every use case. Replaces the
|
|
3
|
-
# ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store
|
|
3
|
+
# ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store
|
|
4
|
+
# (see Store#initialize); Store delegates its readers to this record,
|
|
5
|
+
# so this `Data.define` is the single source of truth for the field set.
|
|
4
6
|
Container = Data.define(
|
|
5
7
|
:manifest, :file_store, :schemas, :root,
|
|
6
8
|
:audit_log, :events, :rpc
|
|
7
9
|
)
|
|
8
|
-
|
|
9
|
-
class Container
|
|
10
|
-
def self.from_store(store)
|
|
11
|
-
new(
|
|
12
|
-
manifest: store.manifest,
|
|
13
|
-
file_store: store.file_store,
|
|
14
|
-
schemas: store.schemas,
|
|
15
|
-
root: store.root,
|
|
16
|
-
audit_log: store.audit_log,
|
|
17
|
-
events: store.events,
|
|
18
|
-
rpc: store.rpc,
|
|
19
|
-
)
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
10
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Contract
|
|
3
|
+
module Resources
|
|
4
|
+
# Serializes builds across every surface (CLI, MCP, Ruby). Previously the
|
|
5
|
+
# CLI verb wrapped each build in a BuildLock by hand; lifting it into the
|
|
6
|
+
# contract means the MCP surface inherits the single-writer guarantee and
|
|
7
|
+
# cannot collide with a concurrent CLI or background build.
|
|
8
|
+
class BuildLock
|
|
9
|
+
def wrap(scope:, inputs:, session: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
10
|
+
Textus::Ports::BuildLock.with(root: scope.container.root) { yield(inputs) }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Textus::Contract::Around.register(:build_lock, Textus::Contract::Resources::BuildLock.new)
|