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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +74 -0
- data/CHANGELOG.md +123 -0
- data/README.md +4 -4
- data/SPEC.md +8 -8
- data/docs/conventions.md +1 -1
- data/lib/textus/application/context.rb +0 -24
- data/lib/textus/application/refresh/orchestrator.rb +3 -2
- data/lib/textus/application/refresh/worker.rb +1 -1
- data/lib/textus/application/writes/accept.rb +3 -2
- data/lib/textus/application/writes/build.rb +101 -9
- data/lib/textus/application/writes/delete.rb +1 -2
- data/lib/textus/application/writes/put.rb +1 -2
- data/lib/textus/builder/pipeline.rb +1 -1
- data/lib/textus/builder/renderer/json.rb +1 -1
- data/lib/textus/builder/renderer/markdown.rb +1 -1
- data/lib/textus/builder/renderer/text.rb +1 -1
- data/lib/textus/builder/renderer/yaml.rb +1 -1
- data/lib/textus/builder/renderer.rb +1 -1
- data/lib/textus/cli/verb/accept.rb +1 -2
- data/lib/textus/cli/verb/audit.rb +1 -2
- data/lib/textus/cli/verb/blame.rb +1 -2
- data/lib/textus/cli/verb/delete.rb +1 -2
- data/lib/textus/cli/verb/freshness.rb +1 -2
- data/lib/textus/cli/verb/get.rb +1 -2
- data/lib/textus/cli/verb/hook_run.rb +1 -1
- data/lib/textus/cli/verb/mv.rb +1 -2
- data/lib/textus/cli/verb/policy_explain.rb +1 -2
- data/lib/textus/cli/verb/put.rb +5 -4
- data/lib/textus/cli/verb/refresh.rb +1 -2
- data/lib/textus/cli/verb/refresh_stale.rb +1 -2
- data/lib/textus/cli/verb/reject.rb +1 -2
- data/lib/textus/cli/verb.rb +14 -0
- data/lib/textus/composition.rb +1 -0
- data/lib/textus/doctor.rb +3 -1
- data/lib/textus/intro.rb +0 -5
- data/lib/textus/manifest/entry.rb +21 -4
- data/lib/textus/manifest.rb +0 -11
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/refresh.rb +1 -2
- data/lib/textus/store/staleness.rb +1 -1
- data/lib/textus/store/writer.rb +1 -1
- data/lib/textus/store.rb +1 -1
- data/lib/textus/version.rb +1 -1
- metadata +2 -5
- data/docs/architecture.md +0 -129
- data/lib/textus/builder.rb +0 -99
- data/lib/textus/publisher.rb +0 -6
- data/lib/textus/store/view.rb +0 -18
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: da7bc5c1fbc90ea76df8adb279491b3a94b4b093f014281ca28c317366539cad
|
|
4
|
+
data.tar.gz: 92b35dab2a95f81d93e45a467652ee840ed06617a3df587d66a170884b99b81f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
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=
|
|
190
|
-
# or just refresh everything stale in the
|
|
191
|
-
textus refresh-stale --zone=
|
|
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 (`
|
|
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
|
|
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:
|
|
220
|
-
zone:
|
|
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
|
|
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=
|
|
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
|
-
|
|
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 <
|
|
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,
|
|
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::
|
|
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)
|
|
@@ -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:
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|