textus 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +74 -0
  3. data/CHANGELOG.md +123 -0
  4. data/README.md +4 -4
  5. data/SPEC.md +8 -8
  6. data/docs/conventions.md +1 -1
  7. data/lib/textus/application/context.rb +0 -24
  8. data/lib/textus/application/refresh/orchestrator.rb +3 -2
  9. data/lib/textus/application/refresh/worker.rb +1 -1
  10. data/lib/textus/application/writes/accept.rb +3 -2
  11. data/lib/textus/application/writes/build.rb +101 -9
  12. data/lib/textus/application/writes/delete.rb +1 -2
  13. data/lib/textus/application/writes/put.rb +1 -2
  14. data/lib/textus/builder/pipeline.rb +1 -1
  15. data/lib/textus/builder/renderer/json.rb +1 -1
  16. data/lib/textus/builder/renderer/markdown.rb +1 -1
  17. data/lib/textus/builder/renderer/text.rb +1 -1
  18. data/lib/textus/builder/renderer/yaml.rb +1 -1
  19. data/lib/textus/builder/renderer.rb +1 -1
  20. data/lib/textus/cli/verb/accept.rb +1 -2
  21. data/lib/textus/cli/verb/audit.rb +1 -2
  22. data/lib/textus/cli/verb/blame.rb +1 -2
  23. data/lib/textus/cli/verb/delete.rb +1 -2
  24. data/lib/textus/cli/verb/freshness.rb +1 -2
  25. data/lib/textus/cli/verb/get.rb +1 -2
  26. data/lib/textus/cli/verb/hook_run.rb +1 -1
  27. data/lib/textus/cli/verb/mv.rb +1 -2
  28. data/lib/textus/cli/verb/policy_explain.rb +1 -2
  29. data/lib/textus/cli/verb/put.rb +5 -4
  30. data/lib/textus/cli/verb/refresh.rb +1 -2
  31. data/lib/textus/cli/verb/refresh_stale.rb +1 -2
  32. data/lib/textus/cli/verb/reject.rb +1 -2
  33. data/lib/textus/cli/verb.rb +14 -0
  34. data/lib/textus/composition.rb +1 -0
  35. data/lib/textus/doctor.rb +3 -1
  36. data/lib/textus/intro.rb +0 -5
  37. data/lib/textus/manifest/entry.rb +21 -4
  38. data/lib/textus/manifest.rb +0 -11
  39. data/lib/textus/projection.rb +1 -1
  40. data/lib/textus/refresh.rb +1 -2
  41. data/lib/textus/store/staleness.rb +1 -1
  42. data/lib/textus/store/writer.rb +1 -1
  43. data/lib/textus/store.rb +1 -1
  44. data/lib/textus/version.rb +1 -1
  45. metadata +2 -5
  46. data/docs/architecture.md +0 -129
  47. data/lib/textus/builder.rb +0 -99
  48. data/lib/textus/publisher.rb +0 -6
  49. data/lib/textus/store/view.rb +0 -18
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca479a6c2f4282b97184aee5c65bc94c3607d18ae0a608756737c70ef53407b5
4
- data.tar.gz: 2294eaa31b51276d48f1a7bc850464c3351a2c93b5d273100192fec4d89561d9
3
+ metadata.gz: da7bc5c1fbc90ea76df8adb279491b3a94b4b093f014281ca28c317366539cad
4
+ data.tar.gz: 92b35dab2a95f81d93e45a467652ee840ed06617a3df587d66a170884b99b81f
5
5
  SHA512:
6
- metadata.gz: 6a6c5a434cf90e8e417faed9034e6ea653f1f94b3e373ea00b47045297e73b9e0f58d4619a84f497b45cb3078930da9b4327d7e179540369af7bf7297db2bd05
7
- data.tar.gz: 4cbcd09254c93d94d1fd06c766ceefb5125990851422db6f6b3af4ad716b7754d71e432ebc88161d938aa5177ec26d6e1f43b7577daa80210e7a493e0266f6ea
6
+ metadata.gz: f84bf665d8aa002bfac744ada1e43b6993d9209f8997cafdfc441f74c758edb8ef28b5b3d45e70f8ccb58cc3c82c05a3238a40940142a35d25fd1807ed237324
7
+ data.tar.gz: cd2326ee626d90217aaf9f9e91b10441d6b397af3bf3bb8c54dde00e5bfa37eca56ca7011175f58168a1b0365a484de76e9ebac3022f035833974442a73e9f1e
data/ARCHITECTURE.md ADDED
@@ -0,0 +1,74 @@
1
+ # Textus architecture
2
+
3
+ ```
4
+ ┌─ Interface ────────────────────────────────────────────────┐
5
+ │ CLI verbs: ctx = Composition.context(store, role:) │
6
+ │ Composition.<use_case>(ctx).call(...) │
7
+ └──────────────────────┬─────────────────────────────────────┘
8
+
9
+ ┌─ Application ────────▼─────────────────────────────────────┐
10
+ │ Context (per-request: store, role, correlation, │
11
+ │ clock, dry_run; can_read?/can_write?) │
12
+ │ Composition (factory module) │
13
+ │ │
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 │
19
+ └──────────┬───────────────────────────────┬─────────────────┘
20
+ │ uses domain │ uses ports
21
+ ┌─ Domain ─▼─────────────────────────────────────────────────┐
22
+ │ Permission ← NEW: predicate, not Action │
23
+ │ Freshness::Policy Freshness::Verdict │
24
+ │ Freshness::Evaluator Action Outcome │
25
+ └──────────────────────────────────────────┬─────────────────┘
26
+ │ implements
27
+ ┌─ 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) │
35
+ └────────────────────────────────────────────────────────────┘
36
+
37
+ Dependency rule: arrows point DOWN. Domain has zero outbound
38
+ imports. Application imports Domain + Infra (via ports).
39
+ ```
40
+
41
+ ## Read path (`store.get`)
42
+
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.
47
+ 5. `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`.
48
+ 6. If fresh → annotate envelope (`stale: false`, `refreshing: false`) and return.
49
+ 7. Otherwise `policy.decide(verdict) → Action` (data, not behavior).
50
+ 8. `Orchestrator.execute(action, key, as)` interprets the Action:
51
+ - `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`
54
+ 9. Map outcome → envelope annotations (`stale`, `refreshing`, `refresh_error`) and return.
55
+
56
+ ## Write path (`store.put`)
57
+
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.
62
+
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.
64
+
65
+ ## Refresh path (`textus refresh KEY`)
66
+
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`.
72
+ - Invokes the handler under a 30s `Timeout.timeout` budget.
73
+ - 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).
data/CHANGELOG.md CHANGED
@@ -8,6 +8,129 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
8
8
  (currently `textus/2`, embedded in every envelope as `protocol`). The protocol
9
9
  is additive within a major; a new major would change the wire string.
10
10
 
11
+ ## 0.10.1 — Documentation refresh and spec hygiene (2026-05-22)
12
+
13
+ Lightweight maintenance release: documentation refresh plus spec-suite hygiene. No `lib/` changes; no CLI, wire-protocol, or behavioral changes.
14
+
15
+ ### Changed
16
+
17
+ - `docs/architecture.md` deleted. `ARCHITECTURE.md` is now the single source of truth for the layered architecture. Inbound links in `docs/zones.md`, `docs/events.md`, and `textus.gemspec` updated.
18
+ - `SPEC.md` examples and CLI snippets refer to the post-0.9.2 default zone names (`identity` / `inbox` / `review` / `output`) instead of the pre-rename `canon` / `intake` / `pending` / `derived`. Prose that explicitly explains the 0.9.2 rename — including the v0.1 back-compat manifest example and the zone-rename table — is preserved.
19
+ - `README.md` `refresh-stale` examples switched to `--zone=inbox`; the `cat .textus/zones/...` example points at the `output` zone.
20
+ - `ARCHITECTURE.md` layer diagram references `Infra::Publisher` instead of bare `Publisher`.
21
+ - `docs/zones.md` references `Textus::Infra::Publisher` instead of bare `Publisher`.
22
+
23
+ ### Testing
24
+
25
+ - Deleted redundant `spec/proposal_spec.rb` (the same `"2026-05-19-add-bob"` fixture is covered more thoroughly by `spec/application/writes/accept_spec.rb`, the canonical post-0.9.1 home for Application-layer write tests).
26
+ - Extracted `shared_context "textus_store_fixture"` into `spec/support/fixtures.rb`; 28 specs adopt it, replacing the repeated `let(:tmp)` / `let(:root)` / `after { FileUtils.remove_entry(tmp) }` triplet. `spec/spec_helper.rb` now autoloads `spec/support/**/*.rb`.
27
+ - Fixed an `instance_variable_set(:@intake_handler, nil)` anti-pattern in `spec/refresh_spec.rb` — the "no intake declared" case now uses a manifest entry that was never given an intake handler, instead of mutating a private ivar after construction.
28
+
29
+ ## 0.10.0 — Shim removal, signal-based zone detection, Builder extraction (2026-05-22)
30
+
31
+ ### Breaking — Ruby API
32
+
33
+ - `Textus::Publisher` constant removed. Use `Textus::Infra::Publisher`.
34
+ - `Textus::Store::View` class removed. Use `Textus::Application::Context`
35
+ (constructed via `Composition.context(store, role:)`).
36
+ - `Textus::Builder` class removed as a public entry point. Build logic lives
37
+ in `Textus::Application::Writes::Build`. External callers should use
38
+ `Textus::Composition.writes_build(ctx).call` instead of
39
+ `Textus::Builder.new(store).build`. The `Textus::Builder` namespace is
40
+ retained internally only for nested helpers (`Builder::Pipeline`,
41
+ `Builder::Renderer::*`).
42
+ - `Application::Context` no longer exposes `put` / `delete` / `get` / `list`
43
+ / `where` shim methods. Hook callers that receive a Context via the
44
+ `store:` hook keyword must call `ctx.store.put(...)` etc., and explicitly
45
+ pass `as: ctx.role` for write operations.
46
+ - Intake handler return values must use `_meta:` for frontmatter. The
47
+ previous `frontmatter:` legacy key is no longer accepted.
48
+
49
+ ### Fixed
50
+
51
+ - `textus reject` and `textus refresh-stale` now work correctly for stores
52
+ that use the post-0.9.2 default zone names (`review`, `output`).
53
+ Zone-kind detection is now signal-based (driven by `writable_by:`
54
+ membership), not name-based. Stores using the pre-0.9.2 names (`pending`,
55
+ `derived`) continue to work.
56
+ - Event payloads' `store:` keyword now carries a Context whose
57
+ `correlation_id` matches the event payload's top-level `correlation_id`
58
+ key. Previously the `store:` Context received a fresh, unrelated
59
+ `correlation_id`.
60
+
61
+ ### Added
62
+
63
+ - `Textus::Manifest::Entry#in_generator_zone?` and `#in_proposal_zone?`
64
+ predicates. Internal `derived?` retained as an alias of
65
+ `in_generator_zone?`.
66
+ - `:built` and `:published` events now carry `correlation_id` in the
67
+ payload, matching the existing pattern on `:put` / `:deleted` /
68
+ `:accepted`.
69
+
70
+ ### Removed
71
+
72
+ - Legacy zone-purpose annotations for `canon` / `intake` / `pending` /
73
+ `derived` removed from `Textus::Intro::ZONE_PURPOSES`. Custom-named zones
74
+ continue to get no purpose annotation (existing behavior). Stores still
75
+ using the pre-rename default names will simply not get purpose
76
+ annotations on those zones in `textus intro` output.
77
+ - Dead code: `Textus::Manifest#validate_keys!` removed (had no callers).
78
+
79
+ ### Internal
80
+
81
+ - Builder logic fully extracted into `Application::Writes::Build`.
82
+ - CLI verbs now share `context_for(store)` / `resolved_role(store)`
83
+ helpers on `CLI::Verb`.
84
+ - Internal helpers in `Manifest`, `Doctor`, and `Manifest::Entry` are
85
+ properly marked private.
86
+
87
+ ### Unchanged
88
+
89
+ - Wire protocol stays `textus/2`. Envelope shape unchanged.
90
+ - CLI verbs, their flags, and their JSON output shape — unchanged.
91
+ - Manifest YAML schema — unchanged.
92
+ - Event names — unchanged (payload gains `correlation_id` on `:built` /
93
+ `:published`, but no existing key is removed or renamed).
94
+ - Hook DSL — unchanged in shape. The `store:` keyword still passes an
95
+ object that responds to `.get`, `.list`, `.where`. The Context's
96
+ role-aware `with_role` is the recommended construction site for hook
97
+ contexts now.
98
+
99
+ ### Migration recipe
100
+
101
+ ```ruby
102
+ # Hook handlers — before 0.10.0
103
+ Textus.hook(:intake, :my_hook) do |store:, config:, args:|
104
+ store.put("inbox.foo", meta: { ... }, body: "...") # used Context shim
105
+ end
106
+
107
+ # Hook handlers — 0.10.0+
108
+ Textus.hook(:intake, :my_hook) do |store:, config:, args:|
109
+ ctx = store # rename for clarity if desired
110
+ ctx.store.put("inbox.foo", meta: { ... }, body: "...", as: ctx.role)
111
+ end
112
+
113
+ # Intake handler returns — before 0.10.0
114
+ { frontmatter: { ... }, body: "..." } # legacy key
115
+
116
+ # Intake handler returns — 0.10.0+
117
+ { _meta: { ... }, body: "..." } # _meta is the canonical key
118
+ ```
119
+
120
+ If you imported the removed constants directly:
121
+
122
+ ```ruby
123
+ # Before
124
+ Textus::Publisher # removed
125
+ Textus::Store::View # removed
126
+ Textus::Builder.new(store).build(key, ...) # removed
127
+
128
+ # After
129
+ Textus::Infra::Publisher
130
+ Textus::Application::Context # via Composition.context(store, role:)
131
+ Textus::Composition.writes_build(ctx).call(key, ...)
132
+ ```
133
+
11
134
  ## 0.9.2 — Policies, audit verbs, zone rename (2026-05-22)
12
135
 
13
136
  ### Breaking — manifest YAML
data/README.md CHANGED
@@ -77,7 +77,7 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
77
77
 
78
78
  ## What ships today
79
79
 
80
- - **Per-entry formats.** `format: markdown | json | yaml | text` on a manifest entry. `cat .textus/zones/derived/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`, `reducer`).
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`, `reducer`).
81
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
82
  - **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.
83
83
  - **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus key migrate --dry-run|--write` rewrites existing stores with illegal segments deterministically.
@@ -186,9 +186,9 @@ end
186
186
  To keep a batch of stale intake entries current in one shot:
187
187
 
188
188
  ```sh
189
- textus refresh-stale --prefix=working --zone=intake --as=script
190
- # or just refresh everything stale in the intake zone:
191
- textus refresh-stale --zone=intake --as=script
189
+ textus refresh-stale --prefix=working --zone=inbox --as=script
190
+ # or just refresh everything stale in the inbox zone:
191
+ textus refresh-stale --zone=inbox --as=script
192
192
  ```
193
193
 
194
194
  The primitive `Textus.hook(event, name, **opts) { ... }` is also supported. See SPEC.md §5.10 for the full contract.
data/SPEC.md CHANGED
@@ -23,7 +23,7 @@ textus is organized as five composable layers. Each layer has a single responsib
23
23
  | Layer | Name | Responsibility |
24
24
  |---|---|---|
25
25
  | L1 | **Store** | Plain-file backend: `.textus/zones/<zone>/...` with YAML frontmatter + Markdown body, addressed by dotted keys, schema-validated, etag-versioned. |
26
- | L2 | **Sources** | Declared external inputs (`intake` zone): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external runners fetch and pipe results through `textus put`. |
26
+ | L2 | **Sources** | Declared external inputs (the `inbox` zone in the default scaffold; any zone writable by `script`): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external runners fetch and pipe results through `textus put`. |
27
27
  | 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. |
28
28
  | 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"`. |
29
29
  | 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. |
@@ -213,11 +213,11 @@ Every successful write records the resolved role and a wall-clock timestamp in `
213
213
 
214
214
  ### 5.2 Compute layer (derived entries)
215
215
 
216
- Derived entries live in the `derived` zone. They are not authored by hand; their body is produced by projecting over other entries. A derived entry's frontmatter declares a `projection` block:
216
+ Derived entries live in a zone whose `writable_by:` list includes `build` — `output` in the default scaffold. They are not authored by hand; their body is produced by projecting over other entries. A derived entry's frontmatter declares a `projection` block:
217
217
 
218
218
  ```yaml
219
- - key: derived.catalogs.people
220
- zone: derived
219
+ - key: output.catalogs.people
220
+ zone: output
221
221
  projection:
222
222
  select: working.network.org # prefix OR [list of prefixes]
223
223
  pluck: [name, relationship, org]
@@ -250,7 +250,7 @@ publish_to:
250
250
  - .ai/instructions.md
251
251
  ```
252
252
 
253
- When the entry is recomputed, textus copies the in-store file byte-for-byte to each destination. The in-store artifact under `.textus/zones/derived/…` 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.
253
+ 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.
254
254
 
255
255
  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 or marked as managed. Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
256
256
 
@@ -301,13 +301,13 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
301
301
  **Refresh paths.** Two are supported:
302
302
 
303
303
  1. **In-process** — `textus refresh KEY --as=script` resolves the entry's `intake.handler`, invokes the registered `:intake` hook with `(config:, store:, args: {})`, and writes the result under role `script`.
304
- 2. **External runner** — a cron job or agent harness reads `textus list --zone=intake --stale --format=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=script --stdin`. The CLI verb `textus refresh-stale [--prefix=K] [--zone=Z]` drives this loop in one shot.
304
+ 2. **External runner** — a cron job or agent harness reads `textus list --zone=inbox --stale --format=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=script --stdin`. The CLI verb `textus refresh-stale [--prefix=K] [--zone=Z]` drives this loop in one shot.
305
305
 
306
306
  Both paths share the same role gate, audit-log entry, and `:refreshed` event. User-supplied hooks live in `.textus/hooks/**/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
307
307
 
308
308
  ### 5.5 Pending / accept workflow
309
309
 
310
- Pending entries are full proposal patches authored into the `pending` zone, typically by agents or scripts. A pending entry's frontmatter describes the patch it proposes against another zone:
310
+ Proposal entries are full patches authored into a zone whose `writable_by:` list includes `ai` `review` in the default scaffold — typically by agents or scripts. The entry's frontmatter describes the patch it proposes against another zone:
311
311
 
312
312
  ```yaml
313
313
  ---
@@ -324,7 +324,7 @@ Proposed body content.
324
324
 
325
325
  `proposal.target_key` names the entry the patch would create or modify, and `proposal.action` is `put` or `delete`. The remaining frontmatter and body are the proposed new content.
326
326
 
327
- `textus accept <pending-key>` is **human-only**: the resolved role must be `human`. It copies the patch into the target zone, records provenance (originating pending key, original role, original timestamp) in the audit log, and removes the pending entry. Agents and scripts can propose but cannot accept.
327
+ `textus accept <proposal-key>` is **human-only**: the resolved role must be `human`. It copies the patch into the target zone, records provenance (originating proposal key, original role, original timestamp) in the audit log, and removes the proposal entry. Agents and scripts can propose but cannot accept.
328
328
 
329
329
  ### 5.6 Audit log
330
330
 
data/docs/conventions.md CHANGED
@@ -18,7 +18,7 @@ Recommended top-level layout — the spec allows alternatives, but this is what
18
18
  manifest.yaml
19
19
  schemas/ # YAML schema definitions
20
20
  zones/
21
- identity/ # identity, voice, canon — humans only
21
+ identity/ # identity, voice, slow-changing facts — humans only
22
22
  working/ # agent-writable working memory
23
23
  inbox/ # script-fed external inputs
24
24
  review/ # AI proposals awaiting accept
@@ -39,30 +39,6 @@ module Textus
39
39
  dry_run: @dry_run,
40
40
  )
41
41
  end
42
-
43
- # Backward-compat for intake handlers receiving a Context (was Store::View)
44
- # that call store.put/get/delete on it. Slated for removal in 0.10.0.
45
- def put(key, **opts)
46
- opts[:as] ||= role
47
- store.put(key, **opts)
48
- end
49
-
50
- def delete(key, **opts)
51
- opts[:as] ||= role
52
- store.delete(key, **opts)
53
- end
54
-
55
- def get(key, **)
56
- store.get(key, **)
57
- end
58
-
59
- def list(*, **)
60
- store.list(*, **)
61
- end
62
-
63
- def where(*, **)
64
- store.where(*, **)
65
- end
66
42
  end
67
43
  end
68
44
  end
@@ -2,11 +2,12 @@ module Textus
2
2
  module Application
3
3
  module Refresh
4
4
  class Orchestrator
5
- def initialize(worker:, bus:, store_root:, store: nil, detached_spawner: nil)
5
+ def initialize(worker:, bus:, store_root:, store: nil, role: "human", detached_spawner: nil)
6
6
  @worker = worker
7
7
  @bus = bus
8
8
  @store_root = store_root
9
9
  @store = store
10
+ @role = role
10
11
  @detached_spawner = detached_spawner || default_spawner
11
12
  end
12
13
 
@@ -46,7 +47,7 @@ module Textus
46
47
 
47
48
  if thread.alive?
48
49
  thread.kill
49
- store_view = @store ? Textus::Store::View.new(@store) : nil
50
+ store_view = @store ? Textus::Application::Context.new(store: @store, role: @role) : nil
50
51
  payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
51
52
  payload[:store] = store_view if store_view
52
53
  @bus.publish(:refresh_detached, **payload)
@@ -23,7 +23,7 @@ module Textus
23
23
  private
24
24
 
25
25
  def read_view
26
- Store::View.new(@ctx.store)
26
+ Application::Context.new(store: @ctx.store, role: @ctx.role)
27
27
  end
28
28
 
29
29
  def fetch_with_bus(key, mentry)
@@ -17,6 +17,8 @@ module Textus
17
17
 
18
18
  case action
19
19
  when "put"
20
+ # Nested proposal "frontmatter" — the meta to write to the accepted
21
+ # target. Not related to the removed intake-handler legacy bridge.
20
22
  target_meta = env["_meta"]["frontmatter"] || {}
21
23
  target_body = env["body"]
22
24
  Composition.writes_put(@ctx).call(target, meta: target_meta, body: target_body)
@@ -28,9 +30,8 @@ module Textus
28
30
 
29
31
  Composition.writes_delete(@ctx).call(pending_key)
30
32
 
31
- store_view = Store::View.new(@ctx.store)
32
33
  @bus.publish(:accepted,
33
- store: store_view,
34
+ store: @ctx.with_role(@ctx.role),
34
35
  key: pending_key,
35
36
  target_key: target,
36
37
  correlation_id: @ctx.correlation_id)
@@ -1,6 +1,12 @@
1
+ require "fileutils"
2
+
1
3
  module Textus
2
4
  module Application
3
5
  module Writes
6
+ # Materializes generator-zone entries (template + projection) onto disk
7
+ # and copies the result to any configured `publish_to` / `publish_each`
8
+ # targets. Fires `:built` and `:published` events on the bus, tagged with
9
+ # the request's correlation_id for traceability.
4
10
  class Build
5
11
  def initialize(ctx:, bus:)
6
12
  @ctx = ctx
@@ -8,15 +14,101 @@ module Textus
8
14
  end
9
15
 
10
16
  def call(prefix: nil)
11
- # Delegate to legacy Builder for the materialization/projection logic.
12
- # Builder fires its own events through @store.fire_event; we do NOT
13
- # double-fire from here. Full extraction of Builder internals into
14
- # Writes::Build is deferred to 0.10.0.
15
- #
16
- # TODO(0.10.0): propagate @ctx.correlation_id through :built/:published
17
- # events once Builder internals are extracted into this use case.
18
- legacy = Textus::Builder.new(@ctx.store)
19
- legacy.build(prefix: prefix)
17
+ built = []
18
+ manifest.entries.each do |mentry|
19
+ next unless mentry.in_generator_zone?
20
+ next unless mentry.projection || mentry.template
21
+ next if prefix && !mentry.key.start_with?(prefix)
22
+
23
+ built << materialize(mentry)
24
+ end
25
+ published_leaves = publish_leaves(prefix: prefix)
26
+ { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => published_leaves }
27
+ end
28
+
29
+ private
30
+
31
+ def store = @ctx.store
32
+ def manifest = store.manifest
33
+ def root = store.root
34
+
35
+ def publish_leaves(prefix: nil)
36
+ repo_root = File.dirname(root)
37
+ out = []
38
+ manifest.entries.each do |mentry|
39
+ next unless mentry.nested && mentry.publish_each
40
+ next if prefix && !mentry.key.start_with?(prefix) && !prefix.start_with?("#{mentry.key}.")
41
+
42
+ manifest.enumerate(prefix: mentry.key).each do |row|
43
+ next unless row[:manifest_entry].equal?(mentry)
44
+ next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
45
+
46
+ out << publish_leaf(mentry, row, repo_root)
47
+ end
48
+ end
49
+ out
50
+ end
51
+
52
+ def publish_leaf(mentry, row, repo_root)
53
+ target_rel = mentry.publish_target_for(row[:key])
54
+ target_abs = File.expand_path(File.join(repo_root, target_rel))
55
+ unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
56
+ raise PublishError.new(
57
+ "entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
58
+ )
59
+ end
60
+
61
+ Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: root)
62
+ publish_event(:published,
63
+ key: row[:key],
64
+ envelope: store.get(row[:key]),
65
+ source: row[:path],
66
+ target: target_abs)
67
+ { "key" => row[:key], "source" => row[:path], "target" => target_abs }
68
+ end
69
+
70
+ def materialize(mentry)
71
+ target_path = Builder::Pipeline.run(
72
+ store: store,
73
+ mentry: mentry,
74
+ template_loader: ->(name) { read_template(name) },
75
+ )
76
+ publish_and_fire(mentry, target_path)
77
+ { "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
78
+ end
79
+
80
+ def read_template(name)
81
+ tpl_path = File.join(root, "templates", name)
82
+ raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
83
+
84
+ File.read(tpl_path)
85
+ end
86
+
87
+ def publish_and_fire(mentry, target_path)
88
+ envelope = store.get(mentry.key)
89
+ repo_root = File.dirname(root)
90
+
91
+ mentry.publish_to.each do |rel|
92
+ target_abs = File.join(repo_root, rel)
93
+ Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: root)
94
+ publish_event(:published,
95
+ key: mentry.key,
96
+ envelope: envelope,
97
+ source: target_path,
98
+ target: target_abs)
99
+ end
100
+
101
+ publish_event(:built,
102
+ key: mentry.key,
103
+ envelope: envelope,
104
+ sources: Array(mentry.projection&.fetch("select", nil)).compact)
105
+ end
106
+
107
+ def publish_event(event, **payload)
108
+ # `with_role` returns a Context that preserves the original
109
+ # correlation_id, so hooks reading `store.correlation_id` see the
110
+ # same value as the event's top-level correlation_id key.
111
+ @bus.publish(event, store: @ctx.with_role(@ctx.role), correlation_id: @ctx.correlation_id, **payload)
20
112
  end
21
113
  end
22
114
  end
@@ -22,9 +22,8 @@ module Textus
22
22
  )
23
23
 
24
24
  unless suppress_events
25
- store_view = Store::View.new(@ctx.store)
26
25
  @bus.publish(:deleted,
27
- store: store_view,
26
+ store: @ctx.with_role(@ctx.role),
28
27
  key: key,
29
28
  correlation_id: @ctx.correlation_id)
30
29
  end
@@ -28,9 +28,8 @@ module Textus
28
28
  )
29
29
 
30
30
  unless suppress_events
31
- store_view = Store::View.new(@ctx.store)
32
31
  @bus.publish(:put,
33
- store: store_view,
32
+ store: @ctx.with_role(@ctx.role),
34
33
  key: key,
35
34
  envelope: envelope,
36
35
  correlation_id: @ctx.correlation_id)
@@ -2,7 +2,7 @@ require "fileutils"
2
2
  require "time"
3
3
 
4
4
  module Textus
5
- class Builder
5
+ module Builder
6
6
  module InjectMeta
7
7
  # Returns a new hash with _meta as the first key, per SPEC §6 ordering.
8
8
  def self.call(content_hash, mentry)
@@ -1,7 +1,7 @@
1
1
  require "json"
2
2
 
3
3
  module Textus
4
- class Builder
4
+ module Builder
5
5
  class Renderer
6
6
  class Json < Renderer
7
7
  def call(mentry:, data:)
@@ -1,7 +1,7 @@
1
1
  require "time"
2
2
 
3
3
  module Textus
4
- class Builder
4
+ module Builder
5
5
  class Renderer
6
6
  class Markdown < Renderer
7
7
  def call(mentry:, data:)
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- class Builder
2
+ module Builder
3
3
  class Renderer
4
4
  class Text < Renderer
5
5
  def call(mentry:, data:)
@@ -1,7 +1,7 @@
1
1
  require "yaml"
2
2
 
3
3
  module Textus
4
- class Builder
4
+ module Builder
5
5
  class Renderer
6
6
  class Yaml < Renderer
7
7
  def call(mentry:, data:)
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- class Builder
2
+ module Builder
3
3
  # Abstract base for output renderers. Each concrete renderer owns
4
4
  # producing the bytes for one manifest format (markdown/json/yaml/text).
5
5
  class Renderer
@@ -6,8 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("accept requires a key")
9
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
- ctx = Textus::Composition.context(store, role: role)
9
+ ctx = context_for(store)
11
10
  emit(Textus::Composition.writes_accept(ctx).call(key))
12
11
  end
13
12
  end
@@ -11,8 +11,7 @@ module Textus
11
11
  option :limit, "--limit=N"
12
12
 
13
13
  def call(store)
14
- role = Role.resolve(flag: nil, env: ENV, root: store.root)
15
- ctx = Textus::Composition.context(store, role: role)
14
+ ctx = context_for(store)
16
15
  since_time = since && Textus::Application::Reads::Audit.parse_since(since, now: ctx.now)
17
16
  rows = Textus::Composition.audit(ctx).call(
18
17
  key: key_filter,
@@ -6,8 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("blame requires a key")
9
- role = Role.resolve(flag: nil, env: ENV, root: store.root)
10
- ctx = Textus::Composition.context(store, role: role)
9
+ ctx = context_for(store)
11
10
  rows = Textus::Composition.blame(ctx).call(key: key, limit: limit&.to_i)
12
11
  emit({ "verb" => "blame", "key" => key, "rows" => rows })
13
12
  end
@@ -7,8 +7,7 @@ module Textus
7
7
 
8
8
  def call(store)
9
9
  key = positional.shift or raise UsageError.new("delete requires a key")
10
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
11
- ctx = Textus::Composition.context(store, role: role)
10
+ ctx = context_for(store)
12
11
  emit(Textus::Composition.writes_delete(ctx).call(key, if_etag: if_etag))
13
12
  end
14
13
  end