textus 0.12.1 → 0.14.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +60 -40
  3. data/CHANGELOG.md +214 -0
  4. data/README.md +6 -12
  5. data/SPEC.md +4 -1
  6. data/docs/conventions.md +8 -8
  7. data/lib/textus/application/context.rb +4 -0
  8. data/lib/textus/application/reads/blame.rb +1 -1
  9. data/lib/textus/application/reads/deps.rb +15 -0
  10. data/lib/textus/application/reads/freshness.rb +2 -2
  11. data/lib/textus/application/reads/get.rb +8 -11
  12. data/lib/textus/application/reads/list.rb +15 -0
  13. data/lib/textus/application/reads/published.rb +15 -0
  14. data/lib/textus/application/reads/rdeps.rb +15 -0
  15. data/lib/textus/application/reads/schema_envelope.rb +15 -0
  16. data/lib/textus/application/reads/stale.rb +15 -0
  17. data/lib/textus/application/reads/uid.rb +15 -0
  18. data/lib/textus/application/reads/validate_all.rb +15 -0
  19. data/lib/textus/application/reads/where.rb +15 -0
  20. data/lib/textus/application/refresh/all.rb +2 -2
  21. data/lib/textus/application/refresh/worker.rb +3 -3
  22. data/lib/textus/application/writes/accept.rb +7 -7
  23. data/lib/textus/application/writes/build.rb +10 -47
  24. data/lib/textus/application/writes/mv.rb +144 -0
  25. data/lib/textus/application/writes/publish.rb +41 -9
  26. data/lib/textus/application/writes/reject.rb +37 -0
  27. data/lib/textus/cli/verb/accept.rb +1 -2
  28. data/lib/textus/cli/verb/audit.rb +3 -3
  29. data/lib/textus/cli/verb/blame.rb +1 -2
  30. data/lib/textus/cli/verb/build.rb +6 -2
  31. data/lib/textus/cli/verb/delete.rb +1 -2
  32. data/lib/textus/cli/verb/deps.rb +1 -1
  33. data/lib/textus/cli/verb/freshness.rb +1 -2
  34. data/lib/textus/cli/verb/get.rb +2 -3
  35. data/lib/textus/cli/verb/list.rb +1 -1
  36. data/lib/textus/cli/verb/mv.rb +1 -1
  37. data/lib/textus/cli/verb/published.rb +1 -1
  38. data/lib/textus/cli/verb/put.rb +2 -2
  39. data/lib/textus/cli/verb/rdeps.rb +1 -1
  40. data/lib/textus/cli/verb/refresh.rb +1 -2
  41. data/lib/textus/cli/verb/reject.rb +1 -1
  42. data/lib/textus/cli/verb/rule_explain.rb +1 -2
  43. data/lib/textus/cli/verb/schema.rb +1 -1
  44. data/lib/textus/cli/verb/uid.rb +1 -1
  45. data/lib/textus/cli/verb/where.rb +1 -1
  46. data/lib/textus/cli/verb.rb +6 -1
  47. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  48. data/lib/textus/doctor.rb +1 -1
  49. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  50. data/lib/textus/domain/policy/predicates/schema_valid.rb +3 -3
  51. data/lib/textus/entry/base.rb +28 -0
  52. data/lib/textus/entry/json.rb +59 -0
  53. data/lib/textus/entry/markdown.rb +46 -0
  54. data/lib/textus/entry/text.rb +35 -0
  55. data/lib/textus/entry/yaml.rb +59 -0
  56. data/lib/textus/entry.rb +16 -0
  57. data/lib/textus/envelope.rb +44 -14
  58. data/lib/textus/intro.rb +56 -0
  59. data/lib/textus/manifest/entry/parser.rb +84 -0
  60. data/lib/textus/manifest/entry/validators/events.rb +21 -0
  61. data/lib/textus/manifest/entry/validators/format_matrix.rb +26 -0
  62. data/lib/textus/manifest/entry/validators/index_filename.rb +45 -0
  63. data/lib/textus/manifest/entry/validators/inject_intro.rb +18 -0
  64. data/lib/textus/manifest/entry/validators/publish_each.rb +37 -0
  65. data/lib/textus/manifest/entry/validators.rb +20 -0
  66. data/lib/textus/manifest/entry.rb +35 -213
  67. data/lib/textus/manifest.rb +6 -16
  68. data/lib/textus/operations/reads.rb +39 -0
  69. data/lib/textus/operations/refresh.rb +27 -0
  70. data/lib/textus/operations/writes.rb +21 -0
  71. data/lib/textus/operations.rb +44 -0
  72. data/lib/textus/projection.rb +5 -4
  73. data/lib/textus/refresh.rb +3 -4
  74. data/lib/textus/schema/tools.rb +8 -7
  75. data/lib/textus/store/reader.rb +1 -1
  76. data/lib/textus/store/validator.rb +3 -3
  77. data/lib/textus/store/writer.rb +5 -74
  78. data/lib/textus/store.rb +1 -55
  79. data/lib/textus/version.rb +1 -1
  80. metadata +23 -4
  81. data/lib/textus/composition.rb +0 -72
  82. data/lib/textus/proposal.rb +0 -10
  83. data/lib/textus/store/mover.rb +0 -167
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e14d58006851be5feba9ffa7016f5860b0ea380cef09cbf324a897d24ae56ee8
4
- data.tar.gz: f928e250b5c3bc6e072c1ffc1928e68fdf61d2965d1746c121657ad4bd07ac75
3
+ metadata.gz: 24eda2a2cfec1884eb3c675189493827b6c0f43d5765922dbee06b70fbdc9685
4
+ data.tar.gz: 84e3850666541900f4c3b99f71c59b73dd142c7001f80fa9e8233d38f984719e
5
5
  SHA512:
6
- metadata.gz: 6eb5690e16fc78f71b42a5f4308d7698684b5aa9f43a92534f73edca5fc36494be65bfab6df2d8d0832f788e7fe7496638ad23f83ee6f69f5a9950b419237745
7
- data.tar.gz: aa3b5903ff1fb44dcc5d1bb3f0676bffb9d8cce99c6e6237f2d947b3486e3d0cfce53271fcf282b927ea1b2e9b1164e721ed0800ddd5ffde1644f85dd660339a
6
+ metadata.gz: d68ad56bc7891aefda10e47234f1bbd1efbfd75b576f7981c3374641efbd306dc66947b382b74c70da24bc58122090c3e0789c33b1a8291aa1005b9e6c952e2e
7
+ data.tar.gz: fd1c1cefad648636b4fb5aeba100d81c66472f8e1b50d07ba2239607c19179842902abf0b76925786c66868049acfd507a9a76bd07e39026dd2aa0d261ebfdad
data/ARCHITECTURE.md CHANGED
@@ -2,73 +2,93 @@
2
2
 
3
3
  ```
4
4
  ┌─ Interface ────────────────────────────────────────────────┐
5
- │ CLI verbs: ctx = Composition.context(store, role:)
6
- Composition.<use_case>(ctx).call(...)
5
+ │ CLI verbs: ops = Operations.for(store, role:)
6
+ ops.reads.<name>.call(...)
7
+ │ ops.writes.<name>.call(...) │
8
+ │ ops.refresh.<name>.call(...) │
7
9
  └──────────────────────┬─────────────────────────────────────┘
8
10
 
9
11
  ┌─ Application ────────▼─────────────────────────────────────┐
10
12
  │ Context (per-request: store, role, correlation, │
11
- │ clock, dry_run; can_read?/can_write?)
12
- Composition (factory module)
13
+ │ clock, dry_run; can_read?/can_write?;
14
+ Context.system(store) for infra path)
15
+ │ Operations (facade with .reads/.writes/.refresh) │
13
16
  │ │
14
- │ reads/get.rb writes/put.rb
15
- refresh/worker.rb writes/delete.rb
16
- refresh/orchestrator.rb writes/build.rb
17
- refresh/all.rb writes/accept.rb
18
- writes/publish.rb
17
+ │ reads/{get,list,where,uid,schema_envelope,deps,rdeps,
18
+ published,stale,validate_all,freshness,audit,
19
+ blame,policy_explain}.rb
20
+ │ writes/{put,delete,mv,accept,reject,build,publish}.rb
21
+ refresh/{worker,orchestrator,all}.rb
19
22
  └──────────┬───────────────────────────────┬─────────────────┘
20
23
  │ uses domain │ uses ports
21
24
  ┌─ Domain ─▼─────────────────────────────────────────────────┐
22
- │ Permission NEW: predicate, not Action
23
- │ Freshness::Policy Freshness::Verdict
24
- Freshness::Evaluator Action Outcome
25
+ │ Permission (write/read predicate per zone)
26
+ │ Freshness::{Policy,Verdict,Evaluator}
27
+ │ Action Outcome
28
+ │ Policy::Promotion (proposal accept gates) │
25
29
  └──────────────────────────────────────────┬─────────────────┘
26
30
  │ implements
27
31
  ┌─ Infrastructure ─────────────────────────▼─────────────────┐
28
- │ Store (pure adapter — exposes ports only) │
29
- │ Reader#read_envelope Writer#write_envelope_…
30
- AuditLog, Staleness, Validator, Mover
31
- Manifest (incl. permission_for)
32
- Hooks::Registry EventBus Clock
33
- Refresh::Lock Refresh::Detached
34
- Infra::Publisher (file copy + sentinel)
32
+ │ Store (pure adapter — filesystem + hooks) │
33
+ │ Reader#{get,list,where,uid,deps,rdeps,published,
34
+ stale,validate_all,read_raw_envelope,
35
+ schema_envelope}
36
+ Writer#{write_envelope_to_disk,delete_envelope_from_
37
+ disk,existing_uid_for,ensure_uid,
38
+ enforce_name_match!,serialize_for_put}
39
+ │ AuditLog, Staleness, Validator, Sentinel │
40
+ │ Manifest (Entry, Rules, Schema, permission_for) │
41
+ │ Hooks::{Registry,Dispatcher,Loader,Dsl,Builtin} │
42
+ │ Infra::{Publisher,EventBus,Clock,Refresh::Lock, │
43
+ │ Refresh::Detached} │
44
+ │ Entry::{Markdown,Json,Yaml,Text} (format strategies) │
35
45
  └────────────────────────────────────────────────────────────┘
36
46
 
37
47
  Dependency rule: arrows point DOWN. Domain has zero outbound
38
48
  imports. Application imports Domain + Infra (via ports).
39
49
  ```
40
50
 
41
- ## Read path (`store.get`)
51
+ ## Read path (`ops.reads.get.call(key)`)
42
52
 
43
- 1. CLI verb (or any caller) invokes `store.get(key, as:)`.
44
- 2. `Store#get` constructs `Reads::Get(store, orchestrator)` and calls `.call(key, as:)`.
45
- 3. `Reads::Get#call` reads the envelope from disk via `store.reader.read_raw_envelope(key)`.
46
- 4. Resolves `Manifest::Entry#policy` a `Domain::Freshness::Policy` value.
53
+ 1. CLI verb (or any external caller) builds `ops = Textus::Operations.for(store, role:)` then `ops.reads.get.call(key)`.
54
+ 2. `Operations::Reads#get` returns an `Application::Reads::Get.new(ctx:, orchestrator:)` instance bound to the request context.
55
+ 3. `Reads::Get#call(key)` reads the bare envelope from disk via `@ctx.store.reader.read_raw_envelope(key)`.
56
+ 4. Resolves the manifest rules for the key via `@ctx.store.manifest.rules_for(key)` and extracts the `refresh` policy.
47
57
  5. `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`.
48
58
  6. If fresh → annotate envelope (`stale: false`, `refreshing: false`) and return.
49
59
  7. Otherwise `policy.decide(verdict) → Action` (data, not behavior).
50
- 8. `Orchestrator.execute(action, key, as)` interprets the Action:
60
+ 8. `Refresh::Orchestrator#execute(action, key:)` interprets the `Action`:
51
61
  - `Action::Return` → `Outcome::Skipped`
52
- - `Action::RefreshSync` → run Worker inline → `Refreshed | Failed`
53
- - `Action::RefreshTimed(budget_ms:)` → race Worker thread vs budget; on timeout, kill thread, fire `:refresh_detached`, fork+detach child, return `Outcome::Detached`
62
+ - `Action::RefreshSync` → run `Refresh::Worker` inline → `Refreshed | Failed`
63
+ - `Action::RefreshTimed(budget_ms:)` → race Worker thread vs budget; on timeout, kill thread, fire `:refresh_backgrounded`, fork+detach child, return `Outcome::Detached`
54
64
  9. Map outcome → envelope annotations (`stale`, `refreshing`, `refresh_error`) and return.
55
65
 
56
- ## Write path (`store.put`)
66
+ ## Write path (`ops.writes.put.call(key, ...)`)
57
67
 
58
- 1. CLI verb calls `ctx = Composition.context(store, role:)` then `Composition.writes_put(ctx).call(key, ...)`.
59
- 2. `Writes::Put#call` checks `ctx.can_write?(zone)` — raises `write_forbidden` if denied.
60
- 3. Delegates pure I/O to `Store::Writer#write_envelope_to_disk(key, ...)`.
61
- 4. On success, fires `:put` event via the injected `Infra::EventBus`, including `correlation_id` from the Context.
68
+ 1. CLI verb calls `ops = Operations.for(store, role:)` then `ops.writes.put.call(key, meta:, body:, content:, if_etag:, suppress_events:)`.
69
+ 2. `Writes::Put#call` validates the key, resolves the manifest entry, and checks `@ctx.can_write?(mentry.zone)` — raises `WriteForbidden` if denied.
70
+ 3. Delegates raw I/O to `Store::Writer#write_envelope_to_disk(key, mentry:, payload:, ctx:, if_etag:)`, which:
71
+ - Resolves the path via `Manifest#resolve`
72
+ - Serializes via `Entry.for_format(...).serialize(...)`
73
+ - Validates against schema if declared
74
+ - Etag-checks if `if_etag:` provided (raises `EtagMismatch` on conflict)
75
+ - Writes to disk via `File.binwrite`
76
+ - Appends the audit row
77
+ 4. On success, publishes `:entry_put` via the bus, with `store: @ctx.with_role(@ctx.role)`, `key:`, `envelope:`, `correlation_id:`.
62
78
 
63
- The same pattern applies to `Writes::Delete`, `Writes::Build`, `Writes::Accept`, and `Writes::Publish`: each takes a `Context`, checks permissions at the use-case layer, then delegates raw I/O to the corresponding `Store::Writer` or `Infra::Publisher` primitive.
79
+ The same pattern applies to `Writes::{Delete,Mv,Accept,Reject,Build,Publish}`: each takes a `Context`, checks permissions at the use-case layer, delegates raw I/O to `Store::Writer` or `Infra::Publisher`, and fires the matching event.
64
80
 
65
- ## Refresh path (`textus refresh KEY`)
81
+ ## Refresh path (`ops.refresh.worker.run(key)`)
66
82
 
67
- 1. CLI `Verb::Refresh` calls `Textus::Refresh.call(store, key, as:)`.
68
- 2. That shim instantiates `Application::Refresh::Worker` and runs it.
69
- 3. `Worker#run`:
70
- - Resolves the manifest entry, looks up the `:intake` handler.
71
- - Publishes `:refresh_began` via the injected `Infra::EventBus`.
83
+ 1. CLI `Verb::Refresh` builds `ops = Operations.for(store, role: "runner")` then calls `ops.refresh.worker.run(key)`.
84
+ 2. `Refresh::Worker#run(key)`:
85
+ - Resolves the manifest entry, looks up the intake handler via `store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)`.
86
+ - Publishes `:refresh_started` via the bus.
72
87
  - Invokes the handler under a 30s `Timeout.timeout` budget.
73
88
  - On any error: publishes `:refresh_failed`, then re-raises (or wraps in `UsageError`).
74
- - On success: normalizes the return shape, persists via `store.put`, publishes `:refreshed` (unless the etag is unchanged).
89
+ - On success: normalizes the return shape, persists via `Application::Writes::Put` with `suppress_events: true`, publishes `:entry_refreshed` (unless the etag is unchanged).
90
+ 3. The batch entry point is `Application::Refresh::All.call(ctx, prefix:, zone:)` which lists stale entries via `Application::Reads::Stale`, then runs `Worker#run` per entry, returning a summary envelope `{ refreshed: [...], failed: [...], skipped: [...] }`.
91
+
92
+ ## Infrastructure-side hook dispatch
93
+
94
+ The hook bus needs an `Application::Context` even when fired from inside `Store#initialize` or other infrastructure-side code paths. `Application::Context.system(store)` returns a Context with `role: "human"` and a fresh correlation_id, designed for exactly this case — see `lib/textus/store.rb` (the `:store_loaded` publish), `lib/textus/doctor.rb` (the doctor-check view), and `lib/textus/projection.rb` (the transform_rows view).
data/CHANGELOG.md CHANGED
@@ -9,6 +9,220 @@ 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.14.0 — 2026-05-26
13
+
14
+ ### Breaking (Ruby API only — CLI JSON output unchanged)
15
+
16
+ - `Operations.reads.get.call(...)` and every other use case that returned
17
+ an envelope Hash now returns `Textus::Envelope` (a `Data.define`
18
+ instance). Call `envelope.to_h_for_wire` to recover the previous Hash
19
+ shape for JSON serialization. The Hash shape itself is byte-identical.
20
+ - `Operations.writes.build.call(...)` return shape no longer includes
21
+ `published_leaves`. Call `Operations.writes.publish.call(...)`
22
+ separately for that. The CLI verb `textus build` runs both
23
+ automatically, so CLI users see no change.
24
+
25
+ ### Added
26
+
27
+ - `Textus::Application::Writes::Publish` — new use case that copies
28
+ nested-leaf files to their `publish_each` targets. Fires
29
+ `:file_published`.
30
+ - `Operations.writes.publish` — factory exposing the new use case.
31
+ - `Textus::Envelope` (now a class, was a module) — typed accessors for
32
+ `protocol`, `key`, `zone`, `owner`, `path`, `format`, `uid`, `etag`,
33
+ `schema_ref`, `meta`, `body`, `content`, `freshness`. Methods:
34
+ `to_h_for_wire`, `stale?`, `refreshing?`.
35
+
36
+ ### Internal
37
+
38
+ - `Application::Writes::Build` trimmed from 116 LOC to ~50 LOC; now
39
+ only materializes generator-zone entries.
40
+ - All ~17 internal `env["..."]` call sites migrated to typed access.
41
+ - `Envelope.build` no longer carries `# rubocop:disable
42
+ Metrics/ParameterLists` (the `Data.define` member list serves the
43
+ same role more clearly).
44
+
45
+ ### Reference
46
+
47
+ - See [ADR 0007](docs/architecture/decisions/0007-envelope-data-class.md).
48
+
49
+ ## 0.13.1 — 2026-05-26
50
+
51
+ ### Internal
52
+
53
+ - `Manifest::Entry` (260 LOC, 11 responsibilities) decomposed into:
54
+ - `Manifest::Entry::Parser` — raw hash → Entry value object.
55
+ - `Manifest::Entry::Validators::*` — one file per validation rule
56
+ (events, publish_each, inject_intro, index_filename, format_matrix).
57
+ - `Manifest::Entry` (~50 LOC) — value object with attr readers,
58
+ zone-kind predicates, and `publish_target_for`.
59
+ - Each validation rule is now independently testable. Adding a new
60
+ rule is one new file under `lib/textus/manifest/entry/validators/`
61
+ plus one line in `Validators::REGISTERED`.
62
+ - Pattern matches the existing `doctor/check/*` (~15 files, same
63
+ shape).
64
+
65
+ ### Compatibility
66
+
67
+ - `Manifest::Entry` keeps all public attribute readers, predicates,
68
+ and `publish_target_for`. External callers consuming `Entry`
69
+ instances see no change. Embedders who subclassed `Entry` may
70
+ need adjustment.
71
+
72
+ ## 0.13.0 — 2026-05-26
73
+
74
+ ### Added
75
+
76
+ - `Textus::Entry::Base` grows 7 abstract class methods that concrete
77
+ strategies must implement: `nested_glob`, `inject_uid`,
78
+ `enforce_name_match!`, `rewrite_name`, `serialize_for_put`,
79
+ `validate_path_extension`.
80
+ - `Textus::Entry.infer_from_extension(ext)` — registry method
81
+ replacing the deleted `Manifest::EXT_TO_FORMAT` constant.
82
+
83
+ ### Changed
84
+
85
+ - Format-specific branches in `Manifest#nested_glob`,
86
+ `Manifest::Entry#{validate_format_matrix!,resolve_format!,
87
+ validate_index_filename!}`, `Store::Writer#{ensure_uid,
88
+ enforce_name_match!,serialize_for_put}`, and
89
+ `Application::Writes::Mv#rewrite_name_for_mv!` all collapse to
90
+ single-line delegations to `Entry.for_format(fmt).<method>(...)`.
91
+ - The 3 rubocop `Metrics/*` disable comments in
92
+ `Manifest::Entry#validate_format_matrix!` are removed; the method
93
+ is now 3 lines.
94
+
95
+ ### Removed
96
+
97
+ - `Textus::Manifest::EXT_TO_FORMAT` constant. Use
98
+ `Textus::Entry.infer_from_extension(ext)` instead.
99
+
100
+ ### Reference
101
+
102
+ - See [ADR 0006](docs/architecture/decisions/0006-format-strategy-extraction.md).
103
+
104
+ ## 0.12.6 — 2026-05-26
105
+
106
+ ### Examples
107
+
108
+ - New `examples/project/` — demonstrates textus as the context store
109
+ for your own project (identity + runbooks + ADR proposal flow,
110
+ projecting `CLAUDE.md` and `AGENTS.md` at the repo root). Staged as
111
+ a fictional Rails service (`ledger`) so the entries read like a real
112
+ codebase, and the pre-staged ADR proposal is `accept`-runnable
113
+ end-to-end (carries a valid `frontmatter:` payload for its target).
114
+ - Refined `examples/claude-plugin/` — reduced to one entry of each kind
115
+ (one agent, one skill, one command, one identity entry, one output,
116
+ one intake recipe). Removed `bin/`, `Rakefile`, `lefthook.yml`, the
117
+ duplicate `github_folder.rb` recipe, and the per-recipe README.
118
+ - Fixed `examples/claude-plugin/recipes/skill_fanout.rb` — the recipe
119
+ routed inner writes through `store.list/put/delete`, which were
120
+ removed in v0.12.2. Now uses `Operations.writes.{put,delete}` against
121
+ the `Application::Context` that hooks actually receive.
122
+ - Updated `spec/examples/skill_fanout_hook_spec.rb` to test the recipe
123
+ against a Context-like duck type, matching the runtime contract.
124
+
125
+ ## 0.12.5 — 2026-05-26
126
+
127
+ ### Documentation
128
+
129
+ - Rewrote `docs/events.md` to use the textus/3 event names from
130
+ `Hooks::Registry::EVENTS` (`:entry_put`, `:entry_deleted`,
131
+ `:build_completed`, `:entry_renamed`, `:proposal_accepted`,
132
+ `:proposal_rejected`, `:file_published`, `:store_loaded`,
133
+ `:entry_refreshed`, `:refresh_started`, `:refresh_failed`,
134
+ `:refresh_backgrounded`). All hook examples now register against
135
+ events that actually exist.
136
+ - Rewrote `docs/zones.md` to use textus/3 vocabulary (`intake` not
137
+ `inbox`, `write_policy:` not `writable_by:`, `rules:` not
138
+ `policies:`, `runner` role not `script`). Manifest fixtures bump
139
+ to `version: textus/3`.
140
+ - Rewrote `ARCHITECTURE.md` to match the v0.12.4 layering:
141
+ `Operations` facade replaces `Composition`, `Application::Reads/Writes`
142
+ use cases replace direct `Store#get/#put` calls, `Store` is pure
143
+ infrastructure.
144
+ - `SPEC.md` Composition → Operations sweep; removed references to
145
+ deleted Store delegators.
146
+ - `docs/recipe-github-skill-bundle.md` updated to use the
147
+ `Operations.writes.{put,delete}` inner-write surface.
148
+
149
+ ## [0.12.4] — 2026-05-26
150
+
151
+ ### Breaking
152
+
153
+ - Removed `Textus::Store#list`, `#where`, `#deps`, `#rdeps`, `#published`,
154
+ `#stale`, `#validate_all`, `#uid`, `#schema_envelope`, and `#fire_event`.
155
+ Use `Textus::Operations.for(store).reads.<name>.call(...)` instead.
156
+ - Removed `Textus::Store::Writer#delete`, `#accept`, `#reject`. Use
157
+ `Textus::Operations.for(store, role:).writes.<verb>.call(...)`.
158
+ - Removed `Textus::Store::Mover`. The mv use case lives entirely in
159
+ `Textus::Application::Writes::Mv` now.
160
+
161
+ ### Added
162
+
163
+ - `Textus::Application::Reads::{List,Where,Uid,SchemaEnvelope,Deps,Rdeps,Published,Stale,ValidateAll}`.
164
+ - `Textus::Application::Writes::Reject`.
165
+ - `Textus::Application::Context.system(store)` for infrastructure-side
166
+ hook dispatch.
167
+
168
+ ### Internal
169
+
170
+ - Internal call sites (`Projection`, `Schema::Tools`, `Application::Refresh::All`,
171
+ `Doctor::Check::SchemaViolations`) now route reads through `Operations`.
172
+
173
+ See [ADR 0005](docs/architecture/decisions/0005-store-facade-final-removal.md).
174
+
175
+ ## [0.12.3] — 2026-05-26
176
+
177
+ ### Added
178
+ - `textus intro` output now includes an `agent_protocol` block: envelope shape, role-resolution rules, and four canonical recipes (`read`, `write`, `propose`, `refresh`). One `textus intro` call is sufficient orientation for a fresh AI agent to operate the store without consulting `SPEC.md`.
179
+
180
+ ### Compatibility
181
+ - Fully additive. All pre-0.12.3 fields on `Textus::Intro.run` retain their existing keys, types, and shapes. The wire `"protocol"` field continues to hold the string `"textus/3"`.
182
+
183
+ ### Examples
184
+ - `examples/claude-plugin/.textus/templates/claude-root.mustache` now projects the new `agent_protocol` recipes into the rendered `CLAUDE.md` via the existing `inject_intro:` mechanism. A plugin's CLAUDE.md auto-projects the four recipes alongside zone authority — agents reading CLAUDE.md get full orientation without a separate `textus intro` call. This is the canonical pattern for plugin authors who want recipes inline.
185
+ - `examples/claude-plugin/` trimmed to one of each surface (one agent, one skill, one command, one identity entry, one JSON output). Removed: the duplicate `fact-checker` variants, the `marketplace.json` projection (the JSON-output lesson is already shown by `plugin.json`), the degenerate `local_file` intake demo (pulled a file from the same store), and unused demo hooks (`rank_by_recency`, `build-stamp`). File count drops from 51 to 33; manifest from 142 to 99 lines.
186
+
187
+ ## [0.12.2] — 2026-05-26
188
+
189
+ ### Breaking changes
190
+
191
+ - **Removed `Textus::Composition`.** All call sites now go through
192
+ `Textus::Operations.for(store, role:)`. The new facade groups use-cases
193
+ by kind: `ops.writes.put`, `ops.reads.get`, `ops.refresh.worker`, etc.
194
+ No alias, no deprecation warning — internal callers update on upgrade.
195
+ - **Removed `Store#put / #get / #delete / #accept / #reject / #mv`.** These
196
+ were thin shims over `Composition`. Use `Operations` directly.
197
+ - **Removed `Writer#put`** (the explicit "Backward-compat shim").
198
+
199
+ ### Internal
200
+
201
+ - Added `Application::Writes::Mv` use-case wrapping `Store::Mover`.
202
+ - Internal Application callers (Accept, Build, Refresh::Worker, Refresh::All,
203
+ Reads::Blame) no longer re-enter via the top-level facade.
204
+ - Audited `spec/` and removed redundant examples (~-48 LOC; most redundancy
205
+ was already absorbed by call-site migration in earlier tasks).
206
+
207
+ See [ADR 0004](docs/architecture/decisions/0004-operations-rename-and-store-facade-removal.md).
208
+
209
+ ### Migration
210
+
211
+ There is no migration path for `Composition` and the Store facade methods —
212
+ they were internal. External consumers (hooks, custom verbs, gem embedders)
213
+ that referenced these symbols must update:
214
+
215
+ ```ruby
216
+ # Before
217
+ ctx = Textus::Composition.context(store, role: "agent")
218
+ Textus::Composition.writes_put(ctx).call(key, body: "...")
219
+ # or:
220
+ store.put(key, body: "...", as: "agent")
221
+
222
+ # After
223
+ Textus::Operations.for(store, role: "agent").writes.put.call(key, body: "...")
224
+ ```
225
+
12
226
  ## 0.12.1 — textus/2 hint fix (2026-05-26)
13
227
 
14
228
  ### Fixed
data/README.md CHANGED
@@ -13,20 +13,12 @@ Reference implementation in Ruby. Wire format `textus/3`. SPEC: [`SPEC.md`](SPEC
13
13
 
14
14
  Two versions, deliberately independent:
15
15
 
16
- - **Protocol wire string:** `textus/3`. Stable; breaking changes require `textus/4`.
17
- - **Gem version:** semver, currently `0.11.0`. The gem version is decoupled from the protocol string — internal refactors bump the gem; only wire-format changes bump the protocol.
16
+ - **Protocol wire string:** `textus/3`. Breaking changes require `textus/4`.
17
+ - **Gem version:** semver, currently `0.14.0`. Decoupled from the protocol string — internal refactors bump the gem; only wire-format changes bump the protocol.
18
18
 
19
19
  Envelope payloads carry the `protocol` field. The gem version is irrelevant to the wire format.
20
20
 
21
- ### Upgrading from textus/2
22
-
23
- textus 0.12.0 does not include a built-in migrator. If you are upgrading from
24
- a textus/2 store (gem versions ≤ 0.10.x), first install textus 0.11.x and run:
25
-
26
- textus migrate --to=textus/3
27
-
28
- Then upgrade to 0.12.0. Pre-0.11.0 audit-log rows with `role: ai|script|build`
29
- are tolerated verbatim by the reader — no rewrite step required.
21
+ See [CHANGELOG.md](CHANGELOG.md) for per-release notes.
30
22
 
31
23
  ## Install
32
24
 
@@ -87,6 +79,8 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
87
79
 
88
80
  - **Per-entry formats.** `format: markdown | json | yaml | text` on a manifest entry. `cat .textus/zones/output/marketplace.json | jq .` works without going through textus — the in-store file *is* the consumer-shaped artifact. Structured outputs carry `_meta` at the top level (`generated_at`, `from`, `template`, `transform`).
89
81
  - **Per-leaf publishing.** Nested entries declare `publish_each: "skills/{basename}/SKILL.md"`. Every leaf byte-copies to its consumer location on `textus build`. No more hand-mirrored `agents/` / `skills/` / `commands/` directories.
82
+ - **Split build/publish (v0.14.0).** `Application::Writes::Build` materializes generator-zone entries; `Application::Writes::Publish` copies nested leaves to `publish_each` targets. The `textus build` CLI verb calls both and merges results, so wire output is unchanged.
83
+ - **Typed envelopes (v0.14.0).** `Textus::Envelope` is a `Data.define` value object with typed accessors (`.meta`, `.body`, `.etag`, `.uid`, `.freshness`, …). Ruby API callers get IDE help and `NoMethodError` on typos. The CLI JSON wire format is preserved byte-for-byte via `envelope.to_h_for_wire`.
90
84
  - **Stable identity (`uid:`).** 16-char hex, auto-minted on first `put`, preserved across writes and moves. `textus key mv old.key new.key` renames in place — uid survives, audit row records `from_key`, `to_key`, `uid`. Reorganising a tree no longer breaks references.
91
85
  - **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus key normalize --dry-run|--write` rewrites existing stores with illegal segments deterministically.
92
86
  - **`textus intro`.** One-shot store orientation: zones with writers + purposes, entry families with schemas and publish targets, loaded hooks, write flows per role, the full CLI verb table. The boot signal for any agent — one tool call and it knows your store.
@@ -162,7 +156,7 @@ Schemas (`.textus/schemas/<name>.yaml`) declare field shapes, per-field `maintai
162
156
  bundle exec rspec
163
157
  ```
164
158
 
165
- ~490 examples; includes conformance fixtures A–I from SPEC §12.
159
+ ~637 examples; includes conformance fixtures A–I from SPEC §12.
166
160
 
167
161
  ## Code quality
168
162
 
data/SPEC.md CHANGED
@@ -828,7 +828,10 @@ Both read and write paths flow through the application layer:
828
828
  - **Reads** flow through `Application::Reads::Get`, which takes a `Context` and dispatches refresh via `Application::Refresh::Orchestrator`.
829
829
  - **Writes** flow through `Application::Writes::{Put,Delete,Build,Accept,Publish}`, each taking a `Context`. Permission checks happen at the use-case layer (via `Context#can_write?`); I/O happens at `Store::Writer#write_envelope_to_disk` (pure).
830
830
  - `Application::Context` is the universal request object: it carries `store`, `role`, `correlation_id`, `clock`, and `dry_run`. Use cases never thread these as separate kwargs.
831
- - `Textus::Composition` is the factory module CLI verbs (and future MCP server / HTTP shim) use to construct Contexts and use cases.
831
+ - `Textus::Operations` is the factory CLI verbs (and future MCP server / HTTP shim)
832
+ use to construct Contexts and use cases. `Operations.for(store, role:)` returns
833
+ a memoized facade with `.reads`, `.writes`, and `.refresh` namespaces mirroring the
834
+ files under `lib/textus/application/{reads,writes,refresh}/`.
832
835
 
833
836
  See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
834
837
 
data/docs/conventions.md CHANGED
@@ -20,7 +20,7 @@ Recommended top-level layout — the spec allows alternatives, but this is what
20
20
  zones/
21
21
  identity/ # identity, voice, slow-changing facts — humans only
22
22
  working/ # agent-writable working memory
23
- inbox/ # script-fed external inputs
23
+ intake/ # runner-fed external inputs
24
24
  review/ # AI proposals awaiting accept
25
25
  output/ # generated by build runners — never edit by hand
26
26
  ```
@@ -82,25 +82,25 @@ Full contract for both shapes is in [`../SPEC.md` §5.2 and §5.2.1](../SPEC.md)
82
82
 
83
83
  ## Intake and freshness
84
84
 
85
- External inputs land via `:intake` hooks, not shell commands. Each inbox entry names a registered handler; refresh is on demand:
85
+ External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler; refresh is on demand:
86
86
 
87
87
  ```sh
88
- textus refresh inbox.notion.roadmap --as=script
89
- textus refresh-stale --zone=inbox --as=script # everything past its TTL
88
+ textus refresh intake.notion.roadmap --as=runner
89
+ textus refresh-stale --zone=intake --as=runner # everything past its TTL
90
90
  ```
91
91
 
92
- Freshness budgets live in the top-level `policies:` block, matched by glob:
92
+ Freshness budgets live in the top-level `rules:` block, matched by glob:
93
93
 
94
94
  ```yaml
95
- policies:
96
- - match: inbox.notion.**
95
+ rules:
96
+ - match: intake.notion.**
97
97
  refresh: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
98
98
  ```
99
99
 
100
100
  A typical scheduled-refresh integration shells the `refresh-stale` sweep itself:
101
101
 
102
102
  ```sh
103
- textus refresh-stale --zone=inbox --as=script # in cron / CI
103
+ textus refresh-stale --zone=intake --as=runner # in cron / CI
104
104
  ```
105
105
 
106
106
  See [`./zones.md` §6](zones.md) for the full intake contract and [`./events.md`](events.md) for writing custom handlers.
@@ -5,6 +5,10 @@ module Textus
5
5
  class Context
6
6
  attr_reader :store, :role, :correlation_id
7
7
 
8
+ def self.system(store)
9
+ new(store: store, role: "human")
10
+ end
11
+
8
12
  def initialize(store:, role:, correlation_id: nil, clock: Time, dry_run: false)
9
13
  @store = store
10
14
  @role = role.to_s
@@ -13,7 +13,7 @@ module Textus
13
13
  end
14
14
 
15
15
  def call(key:, limit: nil)
16
- audit_rows = Textus::Composition.audit(@ctx).call(key: key, limit: limit)
16
+ audit_rows = Textus::Application::Reads::Audit.new(ctx: @ctx).call(key: key, limit: limit)
17
17
  path = resolve_path(key)
18
18
  return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
19
19
 
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class Deps
5
+ def initialize(ctx:)
6
+ @ctx = ctx
7
+ end
8
+
9
+ def call(key)
10
+ @ctx.store.reader.deps(key)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -30,12 +30,12 @@ module Textus
30
30
  set = @ctx.store.manifest.rules_for(mentry.key)
31
31
  refresh = set.refresh
32
32
  envelope = safe_get(mentry.key)
33
- last = envelope&.dig("_meta", "last_refreshed_at")
33
+ last = envelope&.meta&.dig("last_refreshed_at")
34
34
 
35
35
  return base_row(mentry, last).merge(status: :no_policy) if refresh.nil?
36
36
 
37
37
  fp = refresh.to_freshness_policy
38
- verdict = @evaluator.call(fp, envelope || {}, now: @ctx.now)
38
+ verdict = @evaluator.call(fp, envelope, now: @ctx.now)
39
39
  status = if verdict.fresh? then :fresh
40
40
  elsif last.nil? then :never_refreshed
41
41
  else :stale
@@ -40,21 +40,18 @@ module Textus
40
40
  private
41
41
 
42
42
  def annotate(envelope, verdict, refreshing:, refresh_error: nil)
43
- envelope = envelope.dup
44
- envelope["stale"] = verdict.stale?
45
- envelope["stale_reason"] = verdict.reason
46
- envelope["refreshing"] = refreshing
47
- envelope["refresh_error"] = refresh_error if refresh_error
48
- envelope
43
+ fresh = {
44
+ "stale" => verdict.stale?,
45
+ "stale_reason" => verdict.reason,
46
+ "refreshing" => refreshing,
47
+ }
48
+ fresh["refresh_error"] = refresh_error if refresh_error
49
+ envelope.with(freshness: fresh)
49
50
  end
50
51
 
51
52
  # No refresh policy applies to this key — treat as fresh, skip evaluation/orchestration.
52
53
  def annotate_fresh(envelope)
53
- envelope = envelope.dup
54
- envelope["stale"] = false
55
- envelope["stale_reason"] = nil
56
- envelope["refreshing"] = false
57
- envelope
54
+ envelope.with(freshness: { "stale" => false, "stale_reason" => nil, "refreshing" => false })
58
55
  end
59
56
  end
60
57
  end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class List
5
+ def initialize(ctx:)
6
+ @ctx = ctx
7
+ end
8
+
9
+ def call(prefix: nil, zone: nil)
10
+ @ctx.store.reader.list(prefix: prefix, zone: zone)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class Published
5
+ def initialize(ctx:)
6
+ @ctx = ctx
7
+ end
8
+
9
+ def call
10
+ @ctx.store.reader.published
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class Rdeps
5
+ def initialize(ctx:)
6
+ @ctx = ctx
7
+ end
8
+
9
+ def call(key)
10
+ @ctx.store.reader.rdeps(key)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class SchemaEnvelope
5
+ def initialize(ctx:)
6
+ @ctx = ctx
7
+ end
8
+
9
+ def call(key)
10
+ @ctx.store.reader.schema_envelope(key)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class Stale
5
+ def initialize(ctx:)
6
+ @ctx = ctx
7
+ end
8
+
9
+ def call(prefix: nil, zone: nil)
10
+ @ctx.store.reader.stale(prefix: prefix, zone: zone)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end