textus 0.54.0 → 0.54.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f98dbc5f461b8cffaa4cf8fb17c8b79cf51f4c5f78af62527e0abe27d84d828
4
- data.tar.gz: 0bfd928765a9797078f7534aecbf89321de754a4d56ee71986c00fb6a3a7b255
3
+ metadata.gz: '08d1e232ff648999588cfcfe0cccafd7954a020805f3e344aabdbbb2798b51af'
4
+ data.tar.gz: 4b8545d90d8aa990d63565f1d2caaff7e3e519d7504ae22a04dad68507c90949
5
5
  SHA512:
6
- metadata.gz: 32fd23741a0f3e69368dfde1fb8733381190b17eb592c459079020a692c98c181a689ab5a83af6b3bf2d16c37ab2b52e40faf6a47ce876e4bafc0709e082ebed
7
- data.tar.gz: daadc9db41cf87e2a348ddf9b4472fc515a48b4f5e7ca77d9c00a6c95ed736d26c59b3ad1ec799a42e3eba940052236dc77e5ee3edaf4737150e8f030a5fbcaa
6
+ metadata.gz: 28b8de7a9e84208008c7082cd597d6905b743fe035b0344953cc82d5581f8a6ce98abf4bae40e5f1040780a1874743e7403695831709a539ea2cd4fd7dbc4c79
7
+ data.tar.gz: 9be159077a4f4e17e0118b3efea9085d3e5f6f463d7f558d6fcfdc45912c07cde6fe0824de213171806245cbca3b38f689f6b6922a2f7ddf9fd4dcb0c0bdc2a7
data/CHANGELOG.md CHANGED
@@ -9,6 +9,24 @@ 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.54.1 — 2026-06-17 — MCP SDK, dry ecosystem, ERB templates
13
+
14
+ ### Added
15
+
16
+ - **`Textus::Types` module**: shared `dry-types` constrained types (`RoleName`, `Cursor`, `FormatName`) used across all dry-struct structs.
17
+
18
+ ### Changed
19
+
20
+ - **MCP surface → official `mcp` SDK** (PR #223): the hand-rolled JSON-RPC 2.0 stdio loop is replaced by the official `mcp` gem (`~> 0.20`). Protocol version auto-negotiates up to `2025-11-25` (was hardcoded `2024-11-05`). `Catalog.build_tools` generates `MCP::Tool` instances from verb contracts; `Server` manages session lifecycle and delegates dispatch. `routing.rb`, the `MCP::Session` alias, and `ToolSchemas` are deleted.
21
+ - **`Session`, `Envelope`, `Container` → `Dry::Struct`** (PR #223): `Data.define` replaced with `Dry::Struct` for typed, loudly-failing construction at object creation time. `Session` enforces `RoleName` and non-negative `Cursor`; `Envelope` enforces known `FormatName`; `Container` has typed port references.
22
+ - **Manifest validator → dry-schema + Semantics** (PR #223): the 318-line programmatic validator is split into `contract.rb` (dry-schema structural contract) and `semantics.rb` (cross-field rules, `walk`-based unknown-key detection, ADR migration hints). Public interface `Validator.validate!` unchanged.
23
+ - **Mustache → ERB** (PR #223): `mustache` gem removed; all 8 `.mustache` templates converted to `.erb` using `ERB#result_with_hash`. `Produce::Render` now uses `ERB.new(template, trim_mode: "-").result_with_hash(ctx)`.
24
+
25
+ ### Removed
26
+
27
+ - `mustache` runtime dependency (replaced by Ruby stdlib ERB).
28
+ - `MCP::Routing` module, `MCP::Session` alias, `MCP::ToolSchemas` module — superseded by the SDK.
29
+
12
30
  ## 0.54.0 — 2026-06-16 — Workflow system, raw ingest pipeline, MCP resources
13
31
 
14
32
  ### Added
data/README.md CHANGED
@@ -64,22 +64,20 @@ DURABLE │ notebook │ knowledge ★ the goal
64
64
  (kept) │ agent's working truth │ canon — a human authors │
65
65
  │ durable, but low-trust │ here · the context you ship │
66
66
  ├──────────────────────────┼───────────────────────────────┤
67
- TRANSIENT │ artifacts.feeds.* │ proposals (queue) │
68
- (staging) │ raw external input, │ a candidate, in review │
69
- unverified (machine) │ ▲ climbs via human accept │
67
+ TRANSIENT │ artifacts.* │ proposals (queue) │
68
+ (staging) │ computed outputs and │ a candidate, in review │
69
+ external inputs │ ▲ climbs via human accept │
70
70
  └──────────────────────────┴───────────────────────────────┘
71
71
  raw material ──── propose ────► a human accept lifts it to canon
72
72
  ```
73
73
 
74
- *(The `machine` lane's other half, `artifacts.derived.*`, isn't on this grid — it's a computed **output** projected from the lanes, not an input climbing toward trust.)*
75
-
76
74
  Without coordination, they overwrite each other and nothing remembers why. textus gives each actor a **lane** — enforced at the protocol level, not by convention — routes everything they can't write directly through a **proposals queue**, and writes every successful change to an **append-only audit log**.
77
75
 
78
76
  ```
79
- knowledge/ author only — who you are, what you decide, how you sound (knowledge.identity.* for identity facts)
80
- notebook/ keep only — agent's own durable lane (agents keep theirs; bytes climb to knowledge only via propose→accept)
81
- proposals/ propose (agent + human) — proposals waiting on a human accept
82
- artifacts/ converge only — machine-maintained: external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)
77
+ knowledge/ author only — who you are, what you decide, how you sound
78
+ notebook/ keep only — agent's own durable lane (bytes climb to knowledge only via propose→accept)
79
+ proposals/ propose (agent+human) — proposals waiting on a human accept
80
+ artifacts/ converge only — machine-maintained: computed outputs + external inputs
83
81
  ```
84
82
 
85
83
  An agent that tries to write directly into `knowledge/` gets `write_forbidden`. It writes to `proposals/` (to change authoritative content) or its own `notebook/` (for working memory). You accept the good proposals; textus promotes them, records the move, and audits both halves. Stable per-entry `uid:` means a reorganization doesn't break references. A monotonic audit cursor (`textus pulse --since=N`) means the next session — possibly a different agent, possibly a different model — picks up exactly where the last one left off.
@@ -109,12 +107,12 @@ Try the gate the other way (`textus put knowledge.notes.X --as=agent`) and you g
109
107
 
110
108
  ## Try it
111
109
 
112
- - **Worked end-to-end store** — the role gate (propose → accept), drain/publish (`CLAUDE.md` / `AGENTS.md` generated from knowledge entries), schemas, templates, and a hook: [`.textus/`](.textus/)
110
+ - **Worked end-to-end store** — the role gate (propose → accept), drain/publish (`CLAUDE.md` / `AGENTS.md` generated from knowledge entries), schemas, ERB templates, and workflows: [`.textus/`](.textus/)
113
111
  - **Wire textus into Claude Code via MCP** — 4 steps, ~5 minutes: [`docs/how-to/agents-mcp.md`](docs/how-to/agents-mcp.md)
114
112
 
115
113
  ## Protocol, not just a gem
116
114
 
117
- This Ruby gem is the reference implementation of **`textus/3`** — a wire format and storage convention any language can speak. The protocol owns the envelope shape, the role/lane gate, the audit log format, and the key grammar. The gem version (semver, see badge) and the protocol version (`textus/3`) move independently; envelopes carry the `protocol` field so consumers can pin to the contract, not the implementation.
115
+ This Ruby gem is the reference implementation of **`textus/4`** — a wire format and storage convention any language can speak. The protocol owns the envelope shape, the role/lane gate, the audit log format, and the key grammar. The gem version (semver, see badge) and the protocol version (`textus/4`) move independently; envelopes carry the `protocol` field so consumers can pin to the contract, not the implementation.
118
116
 
119
117
  - Specification: [`SPEC.md`](SPEC.md)
120
118
  - Architecture: [`docs/architecture/README.md`](docs/architecture/README.md)
@@ -146,26 +144,26 @@ roles:
146
144
  - { name: automation, can: [converge] }
147
145
 
148
146
  lanes:
149
- - { name: knowledge, kind: canon } # author — canonical truth
150
- - { name: notebook, kind: workspace } # keep — agent's own durable lane
151
- - { name: proposals, kind: queue } # propose — proposals awaiting accept
152
- - { name: artifacts, kind: machine } # converge — external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)
147
+ - { name: knowledge, kind: canon } # author — canonical truth
148
+ - { name: notebook, kind: workspace } # keep — agent's own durable lane
149
+ - { name: proposals, kind: queue } # propose — proposals awaiting accept
150
+ - { name: artifacts, kind: machine } # converge — computed outputs + external inputs
153
151
  ```
154
152
 
155
153
  ```
156
154
  .textus/
157
155
  manifest.yaml # role capabilities + lane kinds + key-to-path mapping
158
156
  schemas/ # YAML field shapes per entry family
159
- templates/ # mustache templates for derived entries
160
- steps/ # step subclasses: fetch/, transform/, validate/, observe/
157
+ templates/ # ERB templates for produced entries
158
+ workflows/ # Ruby workflow files (Textus.workflow DSL) for data acquisition
161
159
  .gitignore # generated — ignores .run/ and any tracked:false entries
162
160
  data/ # one dir per lane; kinds + capabilities are in the manifest above
163
161
  knowledge/ # e.g. identity (knowledge.identity.*), voice, decisions, notes
164
162
  notebook/
165
163
  proposals/
166
- artifacts/ # machine lane: feeds/ (external inputs) + derived/ (computed outputs)
164
+ artifacts/ # machine lane: computed outputs + external inputs
167
165
  .run/ # disposable runtime state — gitignored, safe to delete (ADR 0038)
168
- audit/audit.log # append-only NDJSON event ledger, every write (rotates at ~50 MB)
166
+ audit/audit.log # append-only NDJSON event ledger, every write (rotates at ~10 MB)
169
167
  state/cursor.<role> # per-role pulse cursor — where `pulse --since` resumes
170
168
  locks/ # per-key produce locks + the produce mutex
171
169
  sentinels/ # publish bookkeeping (target sha) — regenerated on drain (ADR 0070)
@@ -181,21 +179,23 @@ textus list --lane=knowledge
181
179
  printf '%s' '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
182
180
  | textus put knowledge.notes.bob --as=human --stdin
183
181
  textus drain --as=automation # re-pull stale inputs + recompute derived outputs
184
- textus rule list # show every rule block
185
- textus audit --limit=20 # query the audit log
182
+ textus rule list # show every rule block
183
+ textus audit --limit=20 # query the audit log
186
184
  ```
187
185
 
188
- (All verbs return JSON envelopes; `--output=json` is the default and the only format in v1.)
186
+ (All verbs return JSON envelopes; `--output=json` is the default and the only format.)
189
187
 
190
- For a worked store — knowledge entries, a staged proposal, schemas, a template, and a `drain` that publishes `CLAUDE.md` / `AGENTS.md` — see [`.textus/`](.textus/).
188
+ For a worked store — knowledge entries, a staged proposal, schemas, ERB templates, and a `drain` that publishes `CLAUDE.md` / `AGENTS.md` — see [`.textus/`](.textus/).
191
189
 
192
190
  ## What's shipped
193
191
 
194
192
  - **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; a typed `publish:` block (`to:` for file fan-out, `tree:` for a whole-subtree mirror) byte-copies derived files to their consumer paths. ([SPEC §5.2–5.3](SPEC.md))
195
193
  - **Stable identity.** Auto-minted `uid:` survives writes and `textus key mv`; reorganising never breaks references.
196
194
  - **Capability × lane-kind gate.** Writes carry `--as=<role>`; a role may write a lane iff it holds the capability the lane's `kind:` requires (`canon`→`author`, `workspace`→`keep`, `machine`→`converge`, `queue`→`propose`). The wrong role gets `write_forbidden` naming the capability needed and the roles that hold it. ([SPEC §5](SPEC.md))
197
- - **Agent loop.** `textus boot` orients a fresh session; `textus pulse --since=N` is the per-turn heartbeat (changed entries, stale keys, pending proposals). ([docs/how-to/agents-mcp.md](docs/how-to/agents-mcp.md))
198
- - **`textus doctor`.** Health checks across schemas, step registrations, keys, sentinels, and the audit log.
195
+ - **Agent loop.** `textus boot` orients a fresh session; `textus pulse --since=N` is the per-turn heartbeat (changed entries, pending proposals, index etag for catalog drift detection). ([docs/how-to/agents-mcp.md](docs/how-to/agents-mcp.md))
196
+ - **MCP surface.** The official `mcp` Ruby SDK drives the stdio JSON-RPC server; protocol version auto-negotiated up to `2025-11-25`. Wire textus into Claude Code, Cursor, or any MCP host in one config block.
197
+ - **`textus doctor`.** Health checks across schemas, workflow registrations, keys, sentinels, and the audit log.
198
+ - **`raw` lane kind.** A write-once intake lane for external data that hasn't been reviewed yet; carries the `ingest` capability.
199
199
 
200
200
  ## CLI and lanes
201
201
 
@@ -204,79 +204,59 @@ Every command operates on one store, located in this order: `--root <path>` flag
204
204
  - Full verb table — read, write, health, scaffolding — is in [SPEC §9](SPEC.md).
205
205
  - Lane semantics and the capability × lane-kind mapping live in [SPEC §5](SPEC.md), with the reference in [`docs/reference/zones.md`](docs/reference/zones.md).
206
206
 
207
- `textus boot` prints the same information for the current store: lanes, entry families with schemas, registered steps, write flows, and the verb catalog. Run it inside a store and you get the live picture; reach for the SPEC when you want the contract.
207
+ `textus boot` prints the same information for the current store: lanes, entry families with schemas, registered workflows, write flows, and the verb catalog. Run it inside a store and you get the live picture; reach for the SPEC when you want the contract.
208
208
 
209
209
  ## Produce and publish
210
210
 
211
- Produced entries (`kind: produced`) declare how they're acquired in one `source:` block (ADR 0093/0094); `drain` materialises them:
211
+ Produced entries (`kind: produced`) declare how they're acquired in one `source:` block; `drain` materialises them. Two built-in modes, plus workflows for custom data acquisition:
212
212
 
213
- - **`source: { from: derive, select: [...], pluck:, sort_by:, limit:, transform: name }`** — a *derived* entry: textus computes its data from other entries, then renders it through a template under `.textus/templates/` (markdown/text) or a templateless path that lets a transform hook shape the output directly (json/yaml). Projections cap at 1000 rows; the vendored Mustache subset caps at depth 8. No partials, no lambdas, no HTML escaping.
214
- - **`source: { from: fetch, handler: name, ttl: 1h, config: {...} }`** — *intake*: a Step::Fetch handler pulls external bytes on a `ttl` cadence; `drain` re-pulls when the entry goes stale.
215
- - **`source: { from: external, sources: [...] }`** — *externally managed*: an out-of-band command writes the file; textus tracks the declared `sources` for staleness.
213
+ - **`source: { from: external, command: "...", sources: [...] }`** — *externally managed*: an out-of-band command or workflow writes the file; textus tracks staleness via declared `sources`.
214
+ - **`source: { from: external, command: "true", sources: [] }` + a workflow** — *workflow-driven*: a `Textus.workflow` block (in `.textus/workflows/`) acquires and shapes the data on `drain`.
216
215
 
217
- Publishing is one typed `publish:` block (ADR 0052). `publish: { to: [path, ...] }` byte-copies a single produced file to one or more targets. `publish: { tree: "dir" }` on a nested entry mirrors its whole stored subtree to one target directory, preserving layout (path-driven — no keys or template variables). Sentinels for every published file live under `.textus/.run/sentinels/` (git-ignored runtime state, regenerated on drain — ADR 0070). See SPEC §5.2, §5.3, §5.12.
216
+ Publishing is one typed `publish:` block (ADR 0052/0094). Each target is either `{ to: path, template?: name }` for a single file (optionally rendered through an ERB template) or `{ tree: "dir" }` to mirror a whole stored subtree. Sentinels for every published file live under `.textus/.run/sentinels/` (git-ignored, regenerated on drain). See SPEC §5.2, §5.3, §5.12.
218
217
 
219
- ## Extension points
218
+ Templates live in `.textus/templates/` as ERB files (`.erb`). The template receives the entry's `content` hash as local variables via `ERB#result_with_hash`. If `inject_boot: true`, a `boot` variable is also available with the live orientation context.
220
219
 
221
- textus extends through **steps** — subclass the right base, place the file in `.textus/steps/<kind>/`, and `drain` discovers it. Three kinds:
220
+ ## Workflows
222
221
 
223
- **`Step::Fetch`**acquires bytes for an intake entry (`from: fetch`). One class per named handler; `drain` invokes it when the entry is stale.
222
+ textus extends through **workflows** a `Textus.workflow` block placed in `.textus/workflows/**/*.rb`. Each workflow matches a produced entry by key glob, then runs one or more named steps to acquire its data:
224
223
 
225
224
  ```ruby
226
- # .textus/steps/fetch/local_file.rb
227
- module Textus
228
- module Step
229
- class LocalFileFetch < Fetch
230
- def call(config:, args:, **)
231
- path = config["path"] or raise "local-file requires source.config.path"
232
- { "_meta" => { "last_fetched_at" => Time.now.utc.iso8601 },
233
- "body" => File.read(File.expand_path(path)) }
234
- end
235
- end
225
+ # .textus/workflows/artifacts/my_report.rb
226
+ Textus.workflow "my_report" do
227
+ match "artifacts.my-report"
228
+
229
+ step :build do |_, ctx|
230
+ # read from knowledge, fetch external data, compute anything
231
+ rows = ctx.container.manifest.resolver
232
+ .enumerate(prefix: "knowledge.notes")
233
+ .map { |r| { "key" => r[:key], "title" => r[:entry].schema } }
234
+ { "content" => { "entries" => rows } }
236
235
  end
237
236
  end
238
237
  ```
239
238
 
240
- **`Step::Transform`** reshapes projected rows for a `from: derive` entry.
239
+ `drain` discovers all workflow files, matches them against produced entries, and runs the steps. The result is written back to the entry's data path; `publish:` then copies it to its consumer paths.
241
240
 
242
- ```ruby
243
- # .textus/steps/transform/rank_by_recency.rb
244
- module Textus
245
- module Step
246
- class RankByRecencyTransform < Transform
247
- def call(rows:, config:, **)
248
- rows.sort_by { |r| r["updated_at"].to_s }.reverse
249
- end
250
- end
251
- end
252
- end
253
- ```
241
+ ## Hooks
254
242
 
255
- **`Step::Observe`** reacts to lifecycle events (fire-and-forget, 0..N per event):
243
+ Out-of-band event reactions use the hook DSL. Drop a file anywhere in `.textus/workflows/` and use `Textus.hook`:
256
244
 
257
245
  ```ruby
258
- # .textus/steps/observe/log_writes.rb
259
- module Textus
260
- module Step
261
- class LogWritesObserve < Observe
262
- on :entry_written
263
-
264
- def call(key:, envelope:, **)
265
- $textus_event_log << [key, envelope.etag]
266
- end
267
- end
246
+ Textus.hook do |reg|
247
+ reg.on(:entry_written) do |key:, envelope:, **|
248
+ # fire-and-forget — runs after every successful write
249
+ $stderr.puts "wrote #{key} (etag #{envelope.etag[0, 8]})"
268
250
  end
269
251
  end
270
252
  ```
271
253
 
272
254
  Observable events: `:entry_written`, `:entry_deleted`, `:entry_fetched`, `:entry_renamed`, `:entry_produced`, `:entry_published`, `:produce_failed`, `:proposal_accepted`, `:proposal_rejected`, `:store_loaded`, `:session_opened`, `:entry_fetch_started`, `:entry_fetch_failed`.
273
255
 
274
- Stale intake entries are re-pulled by `drain`, not by reads — `get` is a pure
275
- read that annotates the returned envelope with a freshness verdict (ADR 0089).
276
- `drain` re-pulls anything past its `source.ttl` and recomputes derived outputs:
256
+ Stale produced entries are re-materialised by `drain`, not by reads — `get` is a pure read that annotates the returned envelope with a freshness verdict (ADR 0089).
277
257
 
278
258
  ```sh
279
- textus drain --as=automation # re-pull every stale intake + recompute derived
259
+ textus drain --as=automation # re-materialise every stale produced entry
280
260
  textus drain artifacts.feeds --as=automation # scope to one prefix
281
261
  textus get artifacts.feeds.calendar.events # a pure read; carries a freshness verdict
282
262
  ```
@@ -287,7 +267,7 @@ See [`docs/how-to/agents-mcp.md`](docs/how-to/agents-mcp.md) for the agent boot
287
267
 
288
268
  ## Examples
289
269
 
290
- [`.textus/`](.textus/) — textus as a project's own context store (a fictional Rails service, `ledger`). Human-authored `knowledge/` (project facts, runbooks), a staged ADR in `proposals/` showing the agent-propose / human-accept loop, schemas validating each family, a mustache template plus a `Step::Transform` step, and a `drain` that publishes the `artifacts.derived.orientation` projection to `CLAUDE.md` and `AGENTS.md`. Includes a copy-paste adoption recipe for your own repo.
270
+ [`.textus/`](.textus/) — textus as a project's own context store. Human-authored `knowledge/` (project facts, runbooks, ADRs), a staged proposal showing the agent-propose / human-accept loop, schemas validating each family, ERB templates and workflows, and a `drain` that publishes the orientation artifact to `CLAUDE.md` and `AGENTS.md`. Includes a copy-paste adoption recipe for your own repo.
291
271
 
292
272
  ## Tests
293
273
 
data/lib/textus/boot.rb CHANGED
@@ -160,7 +160,7 @@ module Textus
160
160
  "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
161
161
  "orientation" => read_artifact_content(container, "artifacts.orientation"),
162
162
  "context" => read_boot_context(container),
163
- "index_key" => "artifacts.index",
163
+ "index_key" => index_key_if_present(container),
164
164
  "agent_protocol" => agent_protocol(manifest),
165
165
  }.compact
166
166
  end
@@ -188,6 +188,13 @@ module Textus
188
188
  nil
189
189
  end
190
190
 
191
+ def self.index_key_if_present(container)
192
+ res = container.manifest.resolver.resolve("artifacts.index")
193
+ res.path && File.exist?(res.path) ? "artifacts.index" : nil
194
+ rescue Textus::Error
195
+ nil
196
+ end
197
+
191
198
  def self.lanes_for(manifest)
192
199
  manifest.data.declared_lane_kinds.keys.map do |name|
193
200
  verb = manifest.policy.verb_for_lane(name)
@@ -1,10 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+
1
5
  module Textus
2
6
  # Single capability record handed to every use case. Replaces the
3
7
  # ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store
4
8
  # (see Store#initialize); Store delegates its readers to this record,
5
- # so this `Data.define` is the single source of truth for the field set.
6
- Container = Data.define(
7
- :manifest, :file_store, :schemas, :root,
8
- :audit_log, :workflows, :gate
9
- )
9
+ # so this struct is the single source of truth for the field set.
10
+ class Container < Dry::Struct
11
+ attribute :manifest, Types::Any
12
+ attribute :file_store, Types::Any
13
+ attribute :schemas, Types::Any
14
+ attribute :root, Types::String
15
+ attribute :audit_log, Types::Any
16
+ attribute :workflows, Types::Any
17
+ attribute :gate, Types::Any
18
+
19
+ def with(**attrs) = self.class.new(to_h.merge(attrs))
20
+
21
+ def initialize(*)
22
+ super
23
+ freeze
24
+ end
25
+ end
10
26
  end
@@ -9,7 +9,7 @@ module Textus
9
9
  # Note on wire format: `#to_h_for_wire` is intentionally narrower than the
10
10
  # full field set. It emits the legacy keys ("stale", "stale_reason",
11
11
  # "fetching", and "fetch_error" when present) so the CLI JSON wire stays
12
- # byte-identical with textus/3. The gem-side fields `checked_at` and
12
+ # byte-identical with textus/4. The gem-side fields `checked_at` and
13
13
  # `ttl_remaining_ms` are NOT emitted on the wire.
14
14
  Verdict = Data.define(
15
15
  :stale, :fetching, :reason, :fetch_error, :checked_at, :ttl_remaining_ms
@@ -13,13 +13,13 @@ module Textus
13
13
 
14
14
  doc = YAML.safe_load_file(path, aliases: false) || {}
15
15
  version = doc["version"]
16
- return [] if version == "textus/3"
16
+ return [] if version == "textus/4"
17
17
 
18
18
  [{
19
19
  "code" => "protocol_mismatch",
20
20
  "severity" => "error",
21
- "message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
22
- "hint" => "Upgrade the store's manifest version to textus/3 (see CHANGELOG for breaking changes).",
21
+ "message" => "Store reports version=#{version.inspect}; this gem expects textus/4.",
22
+ "hint" => "Upgrade the store's manifest version to textus/4 (see CHANGELOG for breaking changes).",
23
23
  }]
24
24
  end
25
25
 
@@ -31,14 +31,14 @@ module Textus
31
31
 
32
32
  doc = YAML.safe_load_file(path, aliases: false) || {}
33
33
  version = doc["version"]
34
- return [] if version == "textus/3"
34
+ return [] if version == "textus/4"
35
35
 
36
36
  [{
37
37
  "code" => "protocol_mismatch",
38
38
  "level" => "error",
39
39
  "subject" => path,
40
- "message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
41
- "fix" => "Upgrade the store's manifest version to textus/3 (see CHANGELOG for breaking changes).",
40
+ "message" => "Store reports version=#{version.inspect}; this gem expects textus/4.",
41
+ "fix" => "Upgrade the store's manifest version to textus/4 (see CHANGELOG for breaking changes).",
42
42
  }]
43
43
  end
44
44
  end
@@ -0,0 +1,54 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # Warns when a knowledge-lane doc's `**reviewed** YYYY-MM (vX.Y)` stamp
5
+ # is more than MINOR_THRESHOLD minor versions behind Textus::VERSION.
6
+ # The stamp is the human staleness signal declared in docs conventions
7
+ # (knowledge.docs-index); this check makes it machine-enforced.
8
+ class StaleReviewedStamp < Check
9
+ STAMP_RE = /\*\*reviewed\*\*\s+\d{4}-\d{2}\s+\(v(\d+\.\d+(?:\.\d+)?)\)/
10
+ MINOR_THRESHOLD = 5
11
+
12
+ def call
13
+ current_minor = parse_minor(Textus::VERSION)
14
+ issues = []
15
+
16
+ manifest.resolver.enumerate.each do |row|
17
+ next unless row[:key].to_s.start_with?("knowledge.")
18
+ next unless row[:path] && File.file?(row[:path])
19
+
20
+ body = File.read(row[:path])
21
+ m = body.match(STAMP_RE)
22
+ next unless m
23
+
24
+ reviewed_minor = parse_minor(m[1])
25
+ behind = current_minor - reviewed_minor
26
+ next unless behind > MINOR_THRESHOLD
27
+
28
+ issues << stale_issue(row[:key], m[1], behind)
29
+ end
30
+
31
+ issues
32
+ end
33
+
34
+ private
35
+
36
+ def parse_minor(version_str)
37
+ version_str.sub(/\Av/, "").split(".").map(&:to_i)[1] || 0
38
+ end
39
+
40
+ def stale_issue(key, stamp_version, behind)
41
+ current_short = Textus::VERSION[/\A\d+\.\d+/]
42
+ {
43
+ "code" => "stale_reviewed_stamp",
44
+ "level" => "warning",
45
+ "subject" => key.to_s,
46
+ "message" => "reviewed at v#{stamp_version}; current is v#{Textus::VERSION} " \
47
+ "(#{behind} minor versions behind, threshold is #{MINOR_THRESHOLD})",
48
+ "fix" => "review the doc and update the stamp to: **reviewed** YYYY-MM (v#{current_short})",
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/textus/doctor.rb CHANGED
@@ -23,6 +23,7 @@ module Textus
23
23
  Check::GeneratorDrift,
24
24
  Check::RawAssetPaths,
25
25
  Check::NotebookSources,
26
+ Check::StaleReviewedStamp,
26
27
  ].freeze
27
28
 
28
29
  ALL_CHECKS = CHECKS.map(&:name_key).freeze
@@ -1,10 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "dry-struct"
4
+
3
5
  module Textus
4
- Envelope = Data.define(
5
- :protocol, :key, :lane, :owner, :path, :format,
6
- :uid, :etag, :schema_ref, :meta, :body, :content, :freshness
7
- ) do
6
+ class Envelope < Dry::Struct
7
+ attribute :protocol, Types::String
8
+ attribute :key, Types::String
9
+ attribute :lane, Types::String
10
+ attribute :owner, Types::String.optional
11
+ attribute :path, Types::String
12
+ attribute :format, Types::FormatName
13
+ attribute :etag, Types::String
14
+ attribute :uid, Types::String.optional
15
+ attribute :schema_ref, Types::String.optional
16
+ attribute :meta, Types::Hash.default({}.freeze)
17
+ attribute :body, Types::String.optional
18
+ attribute :content, Types::Any.optional
19
+ attribute :freshness, Types::Any.optional
20
+
8
21
  # rubocop:disable Metrics/ParameterLists
9
22
  def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
10
23
  # rubocop:enable Metrics/ParameterLists
@@ -30,6 +43,8 @@ module Textus
30
43
  v.is_a?(String) ? v : nil
31
44
  end
32
45
 
46
+ def with(**attrs) = self.class.new(to_h.merge(attrs))
47
+
33
48
  def to_h_for_wire
34
49
  h = {
35
50
  "protocol" => protocol,
@@ -49,8 +64,6 @@ module Textus
49
64
  h
50
65
  end
51
66
 
52
- alias_method :zone, :lane
53
-
54
67
  def stale?
55
68
  return false if freshness.nil?
56
69
 
data/lib/textus/errors.rb CHANGED
@@ -217,7 +217,7 @@ module Textus
217
217
  def initialize(old_flag, new_flag)
218
218
  super(
219
219
  "flag_renamed",
220
- "#{old_flag} was renamed in textus/3 — use #{new_flag}",
220
+ "#{old_flag} was renamed in textus/4 — use #{new_flag}",
221
221
  details: { "old" => old_flag, "new" => new_flag },
222
222
  hint: "Use #{new_flag} instead.",
223
223
  exit_code: 2,
data/lib/textus/init.rb CHANGED
@@ -6,7 +6,7 @@ module Textus
6
6
  ZONES = %w[knowledge notebook proposals artifacts raw].freeze
7
7
 
8
8
  DEFAULT_MANIFEST = <<~YAML
9
- version: textus/3
9
+ version: textus/4
10
10
  roles:
11
11
  - { name: human, can: [author, propose] }
12
12
  - { name: agent, can: [propose, keep] }
@@ -42,8 +42,8 @@ module Textus
42
42
  path: data/artifacts/derived/orientation.json
43
43
  lane: artifacts
44
44
  publish:
45
- - { to: CLAUDE.md, template: orientation.mustache, inject_boot: true }
46
- - { to: AGENTS.md, template: orientation.mustache, inject_boot: true }
45
+ - { to: CLAUDE.md, template: orientation.erb, inject_boot: true }
46
+ - { to: AGENTS.md, template: orientation.erb, inject_boot: true }
47
47
  kind: produced
48
48
  YAML
49
49
 
@@ -121,7 +121,7 @@ module Textus
121
121
  {
122
122
  "project.schema.yaml" => File.join("schemas", "project.yaml"),
123
123
  "runbook.schema.yaml" => File.join("schemas", "runbook.yaml"),
124
- "orientation.mustache" => File.join("templates", "orientation.mustache"),
124
+ "orientation.erb" => File.join("templates", "orientation.erb"),
125
125
  }.each do |src, dest|
126
126
  File.write(File.join(target_root, dest), File.read(File.join(scaffold_dir, src)))
127
127
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/schema"
4
+
5
+ module Textus
6
+ class Manifest
7
+ module Schema
8
+ # rubocop:disable Metrics/BlockLength
9
+ Contract = Dry::Schema.JSON do
10
+ required(:lanes).value(:array).each do
11
+ hash do
12
+ required(:name).value(:string)
13
+ required(:kind).value(included_in?: Vocabulary::LANE_KINDS)
14
+ optional(:owner).value(:string)
15
+ optional(:desc).value(:string)
16
+ end
17
+ end
18
+
19
+ optional(:roles).value(:array).each do
20
+ hash do
21
+ required(:name).value(:string)
22
+ optional(:can).value(:array).each(:string)
23
+ end
24
+ end
25
+
26
+ optional(:entries).value(:array).each do
27
+ hash do
28
+ required(:key).value(:string)
29
+ required(:lane).value(:string)
30
+ optional(:path).value(:string)
31
+ optional(:owner).value(:string)
32
+ optional(:format).value(:string)
33
+ optional(:schema).maybe(:string)
34
+ optional(:kind).value(:string)
35
+ optional(:nested).value(:bool)
36
+ optional(:tracked).value(:bool)
37
+ optional(:ignore)
38
+ optional(:source)
39
+ optional(:publish).value(:array)
40
+ end
41
+ end
42
+
43
+ optional(:rules).value(:array).each do
44
+ hash do
45
+ optional(:match).value(:string)
46
+ optional(:guard)
47
+ optional(:retention)
48
+ optional(:react)
49
+ end
50
+ end
51
+
52
+ optional(:audit).hash do
53
+ optional(:max_size).value(:integer)
54
+ optional(:keep).value(:integer)
55
+ end
56
+ optional(:version).value(:string)
57
+ end
58
+ # rubocop:enable Metrics/BlockLength
59
+ end
60
+ end
61
+ end