textus 0.45.1 → 0.46.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +53 -26
  4. data/SPEC.md +11 -11
  5. data/docs/architecture/README.md +4 -23
  6. data/lib/textus/boot.rb +1 -0
  7. data/lib/textus/builder/pipeline.rb +11 -42
  8. data/lib/textus/builder/renderer/markdown.rb +4 -8
  9. data/lib/textus/cli.rb +29 -1
  10. data/lib/textus/container.rb +3 -15
  11. data/lib/textus/dispatcher.rb +1 -0
  12. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  13. data/lib/textus/doctor/check/sentinels.rb +1 -1
  14. data/lib/textus/domain/policy/predicates/fresh_within.rb +6 -5
  15. data/lib/textus/envelope/io/writer.rb +34 -0
  16. data/lib/textus/layout.rb +8 -0
  17. data/lib/textus/maintenance/key_delete_prefix.rb +5 -4
  18. data/lib/textus/maintenance/key_mv_prefix.rb +14 -4
  19. data/lib/textus/maintenance/migrate.rb +5 -4
  20. data/lib/textus/maintenance/rule_lint.rb +1 -1
  21. data/lib/textus/maintenance/zone_mv.rb +5 -4
  22. data/lib/textus/ports/publisher.rb +3 -2
  23. data/lib/textus/ports/sentinel_store.rb +8 -7
  24. data/lib/textus/projection.rb +4 -3
  25. data/lib/textus/read/audit.rb +1 -1
  26. data/lib/textus/read/blame.rb +1 -1
  27. data/lib/textus/read/boot.rb +1 -1
  28. data/lib/textus/read/capabilities.rb +70 -0
  29. data/lib/textus/read/deps.rb +1 -1
  30. data/lib/textus/read/doctor.rb +1 -1
  31. data/lib/textus/read/freshness.rb +1 -1
  32. data/lib/textus/read/get.rb +1 -1
  33. data/lib/textus/read/list.rb +1 -1
  34. data/lib/textus/read/published.rb +1 -1
  35. data/lib/textus/read/pulse.rb +1 -1
  36. data/lib/textus/read/rdeps.rb +1 -1
  37. data/lib/textus/read/rule_explain.rb +1 -1
  38. data/lib/textus/read/rule_list.rb +1 -1
  39. data/lib/textus/read/schema_envelope.rb +1 -1
  40. data/lib/textus/read/uid.rb +1 -1
  41. data/lib/textus/read/where.rb +1 -1
  42. data/lib/textus/store.rb +47 -24
  43. data/lib/textus/version.rb +1 -1
  44. data/lib/textus/write/accept.rb +1 -1
  45. data/lib/textus/write/build.rb +1 -1
  46. data/lib/textus/write/delete.rb +1 -1
  47. data/lib/textus/write/fetch_all.rb +1 -1
  48. data/lib/textus/write/fetch_worker.rb +1 -1
  49. data/lib/textus/write/mv.rb +1 -1
  50. data/lib/textus/write/propose.rb +1 -1
  51. data/lib/textus/write/put.rb +1 -1
  52. data/lib/textus/write/reject.rb +1 -1
  53. data/lib/textus/write/retention_sweep.rb +1 -1
  54. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dfb215839c07335a72f604bbcb830d6027a118f500be30243a592cc9c5b07cf0
4
- data.tar.gz: dd0103470acbdfb747077bc6d0fafbc08700edfe5a24d09416723d47517871df
3
+ metadata.gz: cd3d64e647d112827056119d45576eda3b7f6ba95446f7a08522d674fb496d2c
4
+ data.tar.gz: b8c7c4d86eccd1469bdd024732dc0321fb64d837f59451efe1280fd7ae8d96e2
5
5
  SHA512:
6
- metadata.gz: 310065adbf501efcd2b793b896770a9cc7e5a38e41c23852c12b18dfca5cc93cea7af1545bac5e306a667aeac959995c9cf08468a3d86869facec42d11c1501f
7
- data.tar.gz: 938e0ed014c543d2cba7190ab6de0440a36670268b4207af19444934fb28560cda382452c0cb1db26e4c31bfe78b89031c52fbdb0196fc7d67eb360013d58d5f
6
+ metadata.gz: 10096cbffbd633d18a915744ddc4e70b7324ad955525a76fc9fb4b95b32ab8b0781d566e10d1fc6695dd5a795b4721926859030e4c86e661bdef1728304b62aa
7
+ data.tar.gz: d393eee10185c057a7fe727dbb9abb572761e12bcec4b5253cda9a5367d5cd3684850e87432079cd71296f2ff917bd4e1a3e1be5b6484cc7be5ff3c33b4611a0
data/CHANGELOG.md CHANGED
@@ -9,6 +9,19 @@ 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.46.0 — 2026-06-03 — Container is the single source of truth
13
+
14
+ 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.
15
+
16
+ ### Changed
17
+
18
+ - **`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`.
19
+ - **`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.
20
+
21
+ ### Breaking (pre-1.0)
22
+
23
+ - **`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).
24
+
12
25
  ## 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
26
 
14
27
  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
- # agent proposes a change to proposals/
95
- printf '%s' '{"_meta":{"name":"oncall","proposal":{"target_key":"knowledge.notes.oncall","action":"put"}},"body":"Patrick on call.\n"}' \
96
- | textus put proposals.notes.oncall --as=agent --stdin
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, an empty audit log, and a starter manifest. Roles declare capabilities; each zone declares a `kind:`, and write authority is derived from the role's capabilities crossed with the zone's kind:
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 # role capabilities + zone kinds + key-to-path mapping
152
- audit.log # append-only NDJSON, every write
153
- schemas/ # YAML field shapes per entry family
154
- templates/ # mustache templates for derived entries
155
- hooks/ # one .rb per hook
156
- sentinels/ # publish bookkeeping
157
- zones/
158
- knowledge/ # author — identity (knowledge.identity.*), voice, decisions, notes
159
- notebook/ # keep — agent's own durable lane (agents keep theirs)
160
- feeds/ # fetch — declared external inputs (actions)
161
- proposals/ # propose (agent + human) — proposals awaiting accept
162
- artifacts/ # buildcomputed outputs
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 by default; pass `--output=json` explicitly if you prefer.)
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
- All verbs accept `--output=json` and return the envelope defined in [SPEC §8](SPEC.md). Write verbs require `--as=<role>` (role resolution: `--as` → `TEXTUS_ROLE` env → `.textus/role` file → default `human`). Default roles: `human`, `agent`, `automation` (rename or add your own in the manifest's `roles:` block).
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/`. See SPEC §5.2, §5.3, §5.12.
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). Events:
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
- - `:resolve_intake` bring bytes in from elsewhere (returns `{_meta:, body:}`)
213
- - `:transform_rows` — transform rows during projection (returns rows)
214
- - `:validate` custom doctor check (returns issues)
215
- - `:entry_put`, `:entry_deleted`, `:entry_fetched`, `:build_completed`, `:proposal_accepted`, `:file_published`, `:entry_renamed`, `:proposal_rejected`, `:store_loaded` — react to lifecycle events
216
- - `:fetch_started`, `:fetch_failed`, `:fetch_backgrounded` background-fetch lifecycle
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/ # internal: bookkeeping for byte-copied publish targets (see §5.3)
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`, `audit.log`, `schemas/`, `templates/`, `hooks/`, `sentinels/`) live directly under `.textus/`. **All user content lives under `.textus/zones/`.** Manifest `path:` fields are relative to `.textus/zones/` — they do **not** include the `zones/` prefix. Implementations MUST prepend `zones/` to every `path:` when resolving a key to a filesystem location.
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>/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.
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>/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).
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
 
@@ -735,10 +735,10 @@ An entry's `format:` selects a storage strategy. All strategies expose the same
735
735
  **`_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
736
 
737
737
  ```
738
- generated_at, from, template, transform
738
+ from, template, transform
739
739
  ```
740
740
 
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.
741
+ 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
742
 
743
743
  ## 6. Schemas
744
744
 
@@ -899,7 +899,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
899
899
  {
900
900
  "agent_quickstart": {
901
901
  "read_verbs": ["get", "list", "pulse", "schema_show", "boot", "rule_explain", "where", "deps", "rdeps"],
902
- "write_verbs": ["delete", "fetch", "fetch_all", "mv", "propose", "put"],
902
+ "write_verbs": ["accept", "delete", "fetch", "fetch_all", "mv", "propose", "put", "reject"],
903
903
  "writable_zones": ["proposals"],
904
904
  "propose_zone": "proposals",
905
905
  "latest_seq": 1842
@@ -909,7 +909,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
909
909
 
910
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_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
911
 
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); safety scales with blast radius the bulk `*_prefix` ops default to a dry-run Plan, single-key `delete` executes under an optional `if_etag`, and single-key `mv` applies immediately but exposes an optional `dry_run`.
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). 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.
913
913
 
914
914
  `latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
915
915
 
@@ -1010,13 +1010,13 @@ Given the `person` schema and a `put` whose frontmatter omits `relationship`, th
1010
1010
  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
1011
 
1012
1012
  **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, and updates `generated.at` to the build timestamp.
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. 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
1014
 
1015
1015
  **Fixture F — Mustache render:**
1016
1016
  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
1017
 
1018
1018
  **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.
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/.run/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `build` is idempotent.
1020
1020
 
1021
1021
  **Fixture H — Audit log format:**
1022
1022
  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.
@@ -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`, `schema`, `rules`.
248
+ 3. **On demand:** `get`, `put`, `propose`, `fetch`, `schema_show`, `rule_explain`.
249
249
 
250
250
  Manifest drift surfaces as `ContractDrift` (manifest_etag mismatch); 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
- RPC (single handler, declares `caps:`):
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 = { "generated_at" => Time.now.utc.iso8601 }
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
- def self.write_if_changed(target_path, bytes, format)
99
- if File.exist?(target_path)
100
- old_bytes = File.binread(target_path)
101
- if format == "text"
102
- return if old_bytes == bytes
103
- else
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
- "generated" => {
19
- "at" => Time.now.utc.iso8601,
20
- "from" => from,
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
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
@@ -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
@@ -36,6 +36,7 @@ module Textus
36
36
  doctor: Textus::Read::Doctor,
37
37
  boot: Textus::Read::Boot,
38
38
  retainable: Textus::Read::Retainable,
39
+ capabilities: Textus::Read::Capabilities,
39
40
 
40
41
  # Maintenance
41
42
  migrate: Textus::Maintenance::Migrate,
@@ -8,7 +8,7 @@ module Textus
8
8
  # that drift without making `build` scan globally.
9
9
  class OrphanedPublishTargets < Check
10
10
  def call
11
- sdir = File.join(root, Textus::Ports::SentinelStore::DIR)
11
+ sdir = Textus::Layout.sentinels(root)
12
12
  return [] unless File.directory?(sdir)
13
13
 
14
14
  repo_root = File.dirname(root)
@@ -5,7 +5,7 @@ module Textus
5
5
  def call
6
6
  store = Textus::Ports::SentinelStore.new
7
7
  file_stat = Textus::Ports::Storage::FileStat.new
8
- dir = File.join(root, "sentinels")
8
+ dir = Textus::Layout.sentinels(root)
9
9
  return [] unless file_stat.directory?(dir)
10
10
 
11
11
  repo_root = File.dirname(root)
@@ -35,13 +35,14 @@ module Textus
35
35
  private
36
36
 
37
37
  # Domain-pure: reads the stored write timestamp from the envelope's
38
- # freshness (checked_at) or meta (last_fetched_at/generated_at) and
39
- # parses the stored ISO-8601 string. Parsing a stored string is not
40
- # I/O (allowed in domain, ADR 0024).
38
+ # freshness (checked_at) or meta (last_fetched_at) and parses the
39
+ # stored ISO-8601 string. Parsing a stored string is not I/O (allowed
40
+ # in domain, ADR 0024). `generated_at` is intentionally NOT consulted:
41
+ # build-generation time is no longer carried in the artifact (ADR
42
+ # 0070), and fetch-freshness is a fetch concept, not a build one.
41
43
  def written_at(envelope)
42
44
  raw = envelope.freshness&.checked_at ||
43
- envelope.meta&.dig("last_fetched_at") ||
44
- envelope.meta&.dig("generated_at")
45
+ envelope.meta&.dig("last_fetched_at")
45
46
  return raw if raw.is_a?(Time)
46
47
  return nil if raw.nil?
47
48
 
@@ -82,6 +82,7 @@ module Textus
82
82
  raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
83
83
 
84
84
  @file_store.delete(path)
85
+ prune_empty_parents(path)
85
86
  @audit_log.append(
86
87
  role: @call.role, verb: "delete", key: key,
87
88
  etag_before: etag_before, etag_after: nil,
@@ -99,6 +100,7 @@ module Textus
99
100
 
100
101
  FileUtils.mkdir_p(File.dirname(to_path))
101
102
  FileUtils.mv(from_path, to_path)
103
+ prune_empty_parents(from_path)
102
104
  basename = to_key.split(".").last
103
105
  Entry.for_format(new_mentry.format).rewrite_name(to_path, basename)
104
106
  etag_after = Etag.for_file(to_path)
@@ -129,6 +131,38 @@ module Textus
129
131
 
130
132
  private
131
133
 
134
+ # After a file leaves a directory (delete or move-source), remove any
135
+ # now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
136
+ # (F3 of #161). Floored at the entry's *zone directory* — a zone is a
137
+ # declared, first-class container, so its own dir is preserved even when
138
+ # momentarily empty; only the sub-dirs the bulk op carved out are
139
+ # pruned. Stops at the first non-empty ancestor, so a dir holding a
140
+ # `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
141
+ # non-empty dir is silently fine, never fatal to the write.
142
+ def prune_empty_parents(path)
143
+ floor = zone_floor(path)
144
+ return unless floor
145
+
146
+ dir = File.dirname(path)
147
+ while dir.start_with?("#{floor}/") && Dir.empty?(dir)
148
+ Dir.rmdir(dir)
149
+ dir = File.dirname(dir)
150
+ end
151
+ rescue SystemCallError
152
+ nil
153
+ end
154
+
155
+ # The zone directory under which `path` lives (`<root>/zones/<zone>`),
156
+ # or nil if `path` is not under the store's zones tree.
157
+ def zone_floor(path)
158
+ zones_root = File.join(@manifest.data.root, "zones")
159
+ prefix = "#{zones_root}/"
160
+ return nil unless path.start_with?(prefix)
161
+
162
+ zone_seg = path.delete_prefix(prefix).split("/").first
163
+ zone_seg && File.join(zones_root, zone_seg)
164
+ end
165
+
132
166
  def ensure_uid(format, meta, content, existing_uid)
133
167
  Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
134
168
  end
data/lib/textus/layout.rb CHANGED
@@ -29,6 +29,14 @@ module Textus
29
29
  File.join(run(root), "audit")
30
30
  end
31
31
 
32
+ # Sentinels are machine-generated (the published target's sha), not authored
33
+ # source, so they live on the runtime side under `.run/` — git-ignored,
34
+ # regenerated by the next build via content-identical adoption (ADR 0070,
35
+ # superseding ADR 0038's `:config` classification).
36
+ def self.sentinels(root)
37
+ File.join(run(root), "sentinels")
38
+ end
39
+
32
40
  def self.audit_log(root)
33
41
  File.join(audit_dir(root), "audit.log")
34
42
  end