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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +53 -73
- data/lib/textus/boot.rb +8 -1
- data/lib/textus/container.rb +21 -5
- data/lib/textus/core/freshness/verdict.rb +1 -1
- data/lib/textus/doctor/check/protocol_version.rb +6 -6
- data/lib/textus/doctor/check/stale_reviewed_stamp.rb +54 -0
- data/lib/textus/doctor.rb +1 -0
- data/lib/textus/envelope.rb +19 -6
- data/lib/textus/errors.rb +1 -1
- data/lib/textus/init.rb +4 -4
- data/lib/textus/manifest/schema/contract.rb +61 -0
- data/lib/textus/manifest/schema/semantics.rb +232 -0
- data/lib/textus/manifest/schema/validator.rb +24 -294
- data/lib/textus/manifest/schema.rb +1 -4
- data/lib/textus/manifest.rb +1 -3
- data/lib/textus/produce/render.rb +4 -2
- data/lib/textus/session.rb +14 -7
- data/lib/textus/store.rb +4 -4
- data/lib/textus/surfaces/mcp/catalog.rb +22 -10
- data/lib/textus/surfaces/mcp/server.rb +73 -135
- data/lib/textus/types.rb +15 -0
- data/lib/textus/version.rb +2 -2
- data/lib/textus/workflow/runner.rb +15 -0
- metadata +36 -7
- data/lib/textus/surfaces/mcp/routing.rb +0 -51
- data/lib/textus/surfaces/mcp/session.rb +0 -9
- data/lib/textus/surfaces/mcp/tool_schemas.rb +0 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '08d1e232ff648999588cfcfe0cccafd7954a020805f3e344aabdbbb2798b51af'
|
|
4
|
+
data.tar.gz: 4b8545d90d8aa990d63565f1d2caaff7e3e519d7504ae22a04dad68507c90949
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
68
|
-
(staging) │
|
|
69
|
-
│
|
|
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
|
|
80
|
-
notebook/ keep only
|
|
81
|
-
proposals/ propose (agent
|
|
82
|
-
artifacts/ converge only
|
|
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
|
|
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/
|
|
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
|
|
150
|
-
- { name: notebook, kind: workspace } # keep
|
|
151
|
-
- { name: proposals, kind: queue } # propose
|
|
152
|
-
- { name: artifacts, kind: machine } # converge —
|
|
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/ #
|
|
160
|
-
|
|
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:
|
|
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 ~
|
|
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
|
|
185
|
-
textus audit --limit=20
|
|
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
|
|
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,
|
|
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,
|
|
198
|
-
-
|
|
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
|
|
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
|
|
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:
|
|
214
|
-
- **`source: { from:
|
|
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). `
|
|
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
|
-
|
|
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
|
-
|
|
220
|
+
## Workflows
|
|
222
221
|
|
|
223
|
-
|
|
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/
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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-
|
|
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
|
|
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" =>
|
|
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)
|
data/lib/textus/container.rb
CHANGED
|
@@ -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
|
|
6
|
-
Container
|
|
7
|
-
:manifest,
|
|
8
|
-
|
|
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/
|
|
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/
|
|
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/
|
|
22
|
-
"hint" => "Upgrade the store's manifest version to textus/
|
|
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/
|
|
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/
|
|
41
|
-
"fix" => "Upgrade the store's manifest version to textus/
|
|
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
data/lib/textus/envelope.rb
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
|
|
3
5
|
module Textus
|
|
4
|
-
Envelope
|
|
5
|
-
:protocol,
|
|
6
|
-
|
|
7
|
-
|
|
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/
|
|
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/
|
|
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.
|
|
46
|
-
- { to: AGENTS.md, template: orientation.
|
|
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.
|
|
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
|