textus 0.38.0 → 0.39.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 +55 -1
- data/README.md +43 -8
- data/SPEC.md +2 -2
- data/lib/textus/doctor/check/illegal_keys.rb +10 -4
- data/lib/textus/init/templates/machine_intake.rb +45 -0
- data/lib/textus/init.rb +41 -1
- data/lib/textus/layout.rb +17 -4
- data/lib/textus/manifest/entry/base.rb +11 -0
- data/lib/textus/manifest/entry/ignore_matcher.rb +46 -0
- data/lib/textus/manifest/entry/nested.rb +9 -2
- data/lib/textus/manifest/entry/validators/ignore.rb +28 -0
- data/lib/textus/manifest/entry/validators.rb +1 -0
- data/lib/textus/manifest/resolver.rb +2 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +4 -0
- metadata +4 -3
- data/ARCHITECTURE.md +0 -3
- data/docs/conventions.md +0 -148
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af15d8a77f0d71c3fab21dc246545b3a7b84242361c518b1d901c491c63e55c4
|
|
4
|
+
data.tar.gz: 4644479ed6df331a6973806fed302b29446d3806d1793d8ea9d9fa661000e986
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee1cf4024e3583c1e59791b279a2fd0f8c00e426f05391267db0239d1a7d2909c521b94bc27caf62d549d902b0dc7f62847178f0ea17973e14b26689a981302a
|
|
7
|
+
data.tar.gz: 775daa6d3992b0b93d2a57c230fbfc5705b50fec592327356f4fa6ccc4dc39aaacdcbd1c24d2dd878a1b6612fd5e9c3416ede0b42d9ba90978dc8f8461c6d014
|
data/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,60 @@ tracks both additive improvements and breaking protocol bumps independently.
|
|
|
11
11
|
|
|
12
12
|
## Unreleased
|
|
13
13
|
|
|
14
|
+
## 0.39.1 — 2026-06-01 — Feed ergonomics: `feeds.machine` env snapshot + intake cookbook ([ADR 0043](docs/architecture/decisions/0043-feed-ergonomics-without-breaking-core-purity.md))
|
|
15
|
+
|
|
16
|
+
No `textus/3` wire-format change. `textus init` scaffolds an additional
|
|
17
|
+
`nested` feed entry; core intake still makes no implicit network calls
|
|
18
|
+
(SPEC §5.4).
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`textus init` scaffolds `feeds.machines.*` with a local env snapshot
|
|
23
|
+
(ADR 0043).** Generated stores get a `nested` feed entry capturing ambient
|
|
24
|
+
machine context (git HEAD/branch/dirty state, `now`, versions) as an explicit,
|
|
25
|
+
user-owned snapshot — keeping ambient state out of `boot`/`pulse` (which stay
|
|
26
|
+
side-effect-free per ADR 0037) and out of `quarantine` (which means external
|
|
27
|
+
bytes pending validation, where the freshness model does not apply).
|
|
28
|
+
|
|
29
|
+
### Documentation
|
|
30
|
+
|
|
31
|
+
- **Multi-machine environment-scan cookbook recipe** demonstrating the nested
|
|
32
|
+
`feeds.machines.*` pattern.
|
|
33
|
+
- **Examples** updated to use the `feeds.machine` env snapshot, matching
|
|
34
|
+
`textus init` output.
|
|
35
|
+
- **README flow diagram** redesigned to group writers and colour-code roles.
|
|
36
|
+
- **How-to fixes** for zone-rename drift in the agents-mcp guide and the
|
|
37
|
+
`:publish` event name.
|
|
38
|
+
|
|
39
|
+
### Internal
|
|
40
|
+
|
|
41
|
+
- Removed the legacy `ARCHITECTURE.md` redirect stub.
|
|
42
|
+
|
|
43
|
+
## 0.39.0 — 2026-06-01 — Native ignore patterns for entry enumeration ([ADR 0042](docs/architecture/decisions/0042-native-ignore-patterns-for-entry-enumeration.md))
|
|
44
|
+
|
|
45
|
+
No `textus/3` wire-format change. Manifest schema gains one optional, backward-compatible key (`ignore:`); existing manifests are unaffected.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- **Per-entry `ignore:` globs on `nested` entries (ADR 0042).** A `nested`
|
|
50
|
+
entry may declare a list of gitignore-style globs (e.g.
|
|
51
|
+
`["**/node_modules/**", "**/dist/**"]`) to keep vendored or generated
|
|
52
|
+
subtrees out of the store. Patterns are honoured by **one shared filter
|
|
53
|
+
seam** consulted by both resolver enumeration (`list`, `build`) and
|
|
54
|
+
`textus doctor`, evaluated *above* key-legality: an ignored path is excluded,
|
|
55
|
+
never judged. This closes the prior divergence where a store could `list`
|
|
56
|
+
cleanly while `doctor` was red on the same vendored paths. Matching is
|
|
57
|
+
segment-wise globstar — `**` spans zero or more path segments; within a
|
|
58
|
+
segment `*` is anchored and `{a,b}` alternates (stdlib `File.fnmatch`,
|
|
59
|
+
no new dependency). Documented in
|
|
60
|
+
[`docs/reference/zones.md`](docs/reference/zones.md#nested-entries).
|
|
61
|
+
|
|
62
|
+
### Internal
|
|
63
|
+
|
|
64
|
+
- **Dogfood textus in its own repo ([ADR 0041](docs/architecture/decisions/0041-dogfood-textus-in-its-own-repo.md)).**
|
|
65
|
+
A self-development store and MCP wiring for textus's own repository. No change
|
|
66
|
+
to the published gem's behavior.
|
|
67
|
+
|
|
14
68
|
## 0.38.0 — 2026-05-31 — MCP serve acts as agent by default ([ADR 0040](docs/architecture/decisions/0040-mcp-connection-role-and-two-channels.md))
|
|
15
69
|
|
|
16
70
|
No `textus/3` wire-format change; no manifest-schema change.
|
|
@@ -451,7 +505,7 @@ Protocol remains `textus/3`.
|
|
|
451
505
|
`refresh`, `refresh_stale`, `schema`, `rules`). Session state (cursor,
|
|
452
506
|
role, manifest_etag) held server-side. Manifest drift surfaces as
|
|
453
507
|
`ContractDrift` (-32001); cursor expiry as `CursorExpired` (-32002).
|
|
454
|
-
See [`docs/mcp.md`](docs/mcp.md) and [ADR 0015](docs/architecture/decisions/0015-agent-gate-mcp.md).
|
|
508
|
+
See [`docs/reference/mcp.md`](docs/reference/mcp.md) and [ADR 0015](docs/architecture/decisions/0015-agent-gate-mcp.md).
|
|
455
509
|
- `examples/claude-plugin/.mcp.json` and migrated skills/commands/agents —
|
|
456
510
|
zero `textus <verb>` shell strings remain in plugin markdown.
|
|
457
511
|
|
data/README.md
CHANGED
|
@@ -27,15 +27,50 @@ Three actors write to your repo today:
|
|
|
27
27
|
|
|
28
28
|
```mermaid
|
|
29
29
|
flowchart LR
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
subgraph writers["writers — who can write"]
|
|
31
|
+
direction TB
|
|
32
|
+
human(["human"])
|
|
33
|
+
agent(["agent"])
|
|
34
|
+
automation(["automation"])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
human -->|author| knowledge["knowledge<br/>(canon)"]
|
|
38
|
+
agent -->|keep| notebook["notebook<br/>(workspace)"]
|
|
32
39
|
agent -->|propose| proposals["proposals<br/>(queue)"]
|
|
33
|
-
|
|
34
|
-
automation(["automation"]) -->|fetch| feeds["feeds<br/>(quarantine)"]
|
|
40
|
+
automation -->|fetch| feeds["feeds<br/>(quarantine)"]
|
|
35
41
|
automation -->|build| artifacts["artifacts<br/>(derived)"]
|
|
42
|
+
|
|
43
|
+
proposals ==>|human accept| knowledge
|
|
44
|
+
feeds -.->|projection source| artifacts
|
|
45
|
+
knowledge -.->|projection source| artifacts
|
|
46
|
+
|
|
47
|
+
classDef actor fill:#238636,stroke:#2ea043,color:#fff;
|
|
48
|
+
classDef gate fill:#9e6a03,stroke:#bb8009,color:#fff;
|
|
49
|
+
classDef anchor fill:#1f6feb,stroke:#388bfd,color:#fff;
|
|
50
|
+
class human,agent,automation actor;
|
|
51
|
+
class proposals gate;
|
|
52
|
+
class knowledge anchor;
|
|
36
53
|
```
|
|
37
54
|
|
|
38
55
|
*Each actor writes only into its own lane; low-trust input climbs to authoritative lanes only by passing a guarded transition (an agent's proposal needs a human `accept`).*
|
|
56
|
+
*Colour legend: **green** = writers · **amber** = the review gate (`proposals`) · **blue** = the trust anchor (`knowledge`).*
|
|
57
|
+
|
|
58
|
+
The point of those lanes is to **build context you can trust**. Place each lane on two axes — how durable it is, and how much you can rely on it without review — and the value shows up as a climb: the high-trust corner (durable *and* authoritative = `knowledge`) is the one place nothing is *written* directly. It's *earned* by crossing the `accept` gate.
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
LOW TRUST HIGH TRUST
|
|
62
|
+
(unreviewed) (authoritative)
|
|
63
|
+
┌──────────────────────────┬───────────────────────────────┐
|
|
64
|
+
DURABLE │ notebook │ knowledge ★ the goal │
|
|
65
|
+
(kept) │ agent's working truth │ canon — a human authors │
|
|
66
|
+
│ durable, but low-trust │ here · the context you ship │
|
|
67
|
+
├──────────────────────────┼───────────────────────────────┤
|
|
68
|
+
TRANSIENT │ feeds │ proposals (queue) │
|
|
69
|
+
(staging) │ raw external input, │ a candidate, in review │
|
|
70
|
+
│ unverified │ ▲ climbs via human accept │
|
|
71
|
+
└──────────────────────────┴───────────────────────────────┘
|
|
72
|
+
raw material ──── propose ────► a human accept lifts it to canon
|
|
73
|
+
```
|
|
39
74
|
|
|
40
75
|
Without coordination, they overwrite each other and nothing remembers why. textus gives each actor a **lane** — called a **zone** in the manifest and CLI, the term used everywhere technical from here on — routes everything they can't write directly through a **proposals queue**, and writes every successful change to an **append-only audit log**. The lanes are enforced at the protocol level, not by convention.
|
|
41
76
|
|
|
@@ -68,7 +103,7 @@ Try the gate the other way (`textus put knowledge.notes.X --as=agent`) and you g
|
|
|
68
103
|
## Try it
|
|
69
104
|
|
|
70
105
|
- **Worked end-to-end store** — the role gate (propose → accept), build/publish (`CLAUDE.md` / `AGENTS.md` generated from knowledge entries), schemas, templates, and a hook: [`examples/project/`](examples/project/)
|
|
71
|
-
- **Wire textus into Claude Code via MCP** — 4 steps, ~5 minutes: [`docs/agents-mcp.md`](docs/agents-mcp.md)
|
|
106
|
+
- **Wire textus into Claude Code via MCP** — 4 steps, ~5 minutes: [`docs/how-to/agents-mcp.md`](docs/how-to/agents-mcp.md)
|
|
72
107
|
|
|
73
108
|
## Protocol, not just a gem
|
|
74
109
|
|
|
@@ -150,7 +185,7 @@ For a worked store — knowledge entries, a staged proposal, schemas, a template
|
|
|
150
185
|
- **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; `publish_to:`/`publish_each:` byte-copy derived files to their consumer paths. ([SPEC §5.2–5.3](SPEC.md))
|
|
151
186
|
- **Stable identity.** Auto-minted `uid:` survives writes and `textus key mv`; reorganising never breaks references.
|
|
152
187
|
- **Capability × zone-kind gate.** Writes carry `--as=<role>`; a role may write a zone iff it holds the capability the zone's `kind:` requires (`canon`→`author`, `workspace`→`keep`, `quarantine`→`fetch`, `queue`→`propose`, `derived`→`build`). The wrong role gets `write_forbidden` naming the capability needed and the roles that hold it. ([SPEC §5](SPEC.md))
|
|
153
|
-
- **Agent loop.** `textus boot` orients a fresh session; `textus pulse --since=N` is the per-turn heartbeat (changed entries, stale keys, pending proposals). ([docs/agents-mcp.md](docs/agents-mcp.md))
|
|
188
|
+
- **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))
|
|
154
189
|
- **`textus doctor`.** Health checks across schemas, hooks, keys, sentinels, and the audit log.
|
|
155
190
|
|
|
156
191
|
## CLI and zones
|
|
@@ -158,7 +193,7 @@ For a worked store — knowledge entries, a staged proposal, schemas, a template
|
|
|
158
193
|
All verbs accept `--output=json` and return the envelope defined in [SPEC §8](SPEC.md). Write verbs require `--as=<role>` (role resolution: `--as` → `TEXTUS_ROLE` env → `.textus/role` file → default `human`). Default roles: `human`, `agent`, `automation` (rename or add your own in the manifest's `roles:` block).
|
|
159
194
|
|
|
160
195
|
- Full verb table — read, write, health, scaffolding — is in [SPEC §9](SPEC.md).
|
|
161
|
-
- Zone semantics and the capability × zone-kind mapping live in [SPEC §5](SPEC.md), with
|
|
196
|
+
- Zone semantics and the capability × zone-kind mapping live in [SPEC §5](SPEC.md), with the reference in [`docs/reference/zones.md`](docs/reference/zones.md).
|
|
162
197
|
|
|
163
198
|
`textus boot` prints the same information for the current store: zones, entry families with schemas, registered hooks, 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.
|
|
164
199
|
|
|
@@ -213,7 +248,7 @@ See SPEC.md §5.10 for the full hook contract.
|
|
|
213
248
|
|
|
214
249
|
Schemas (`.textus/schemas/<name>.yaml`) declare field shapes, per-field `maintained_by:` ownership, and an `evolution:` block (`added_in`, `deprecated_at`, `migrate_from`). Full contract in SPEC §5.8.
|
|
215
250
|
|
|
216
|
-
See [`docs/agents-mcp.md`](docs/agents-mcp.md) for the agent boot → pulse loop.
|
|
251
|
+
See [`docs/how-to/agents-mcp.md`](docs/how-to/agents-mcp.md) for the agent boot → pulse loop.
|
|
217
252
|
|
|
218
253
|
## Examples
|
|
219
254
|
|
data/SPEC.md
CHANGED
|
@@ -632,7 +632,7 @@ Row transforms are RPC hooks on the `:transform_rows` event. See §5.10.
|
|
|
632
632
|
|
|
633
633
|
### 5.10 Hooks
|
|
634
634
|
|
|
635
|
-
This section is the normative event table. For the hook-author's guide (how to define and test hooks), see [`docs/
|
|
635
|
+
This section is the normative event table. For the hook-author's guide (how to define and test hooks), see [`docs/how-to/writing-hooks.md`](docs/how-to/writing-hooks.md).
|
|
636
636
|
|
|
637
637
|
textus has a single hook registration verb: `Textus.hook { |reg| reg.on(event, name, **opts) { ... } }`. The EVENTS table below defines every extension point. Files in `.textus/hooks/**/*.rb` are `load`ed at `Store#initialize` in alphabetical order by full path; the store-scoped loader drains the queued blocks and invokes each with its own registry.
|
|
638
638
|
|
|
@@ -1003,7 +1003,7 @@ The reference Ruby gem follows semver independently and speaks `textus/3`.
|
|
|
1003
1003
|
|
|
1004
1004
|
Agents interact with a textus store through two verbs: `boot` (once per session, for orientation) and `pulse` (per turn, for deltas). The `boot` envelope's `agent_quickstart` block gives the agent its starting cursor (`latest_seq`), its writable zones, and its propose zone. The `pulse` verb returns a delta envelope keyed on that cursor. When audit log rotation expires a cursor, `CursorExpired` signals the agent to call `boot` again.
|
|
1005
1005
|
|
|
1006
|
-
For the full boot → pulse loop with pseudocode and cursor-expiry handling, see [`docs/agents-mcp.md`](docs/agents-mcp.md).
|
|
1006
|
+
For the full boot → pulse loop with pseudocode and cursor-expiry handling, see [`docs/how-to/agents-mcp.md`](docs/how-to/agents-mcp.md).
|
|
1007
1007
|
|
|
1008
1008
|
## 12. Conformance fixtures
|
|
1009
1009
|
|
|
@@ -11,15 +11,18 @@ module Textus
|
|
|
11
11
|
next unless File.directory?(base)
|
|
12
12
|
|
|
13
13
|
index_fn = entry.respond_to?(:index_filename) ? entry.index_filename : nil
|
|
14
|
-
index_fn ? check_index_paths(entry, index_fn, base, out) : check_all_paths(base, out)
|
|
14
|
+
index_fn ? check_index_paths(entry, index_fn, base, out) : check_all_paths(entry, base, out)
|
|
15
15
|
end
|
|
16
16
|
out
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
|
-
def check_all_paths(base, out)
|
|
21
|
+
def check_all_paths(entry, base, out)
|
|
22
22
|
walk_nested(base) do |abs_path, is_dir|
|
|
23
|
+
rel = abs_path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
24
|
+
next if entry.ignored?(rel)
|
|
25
|
+
|
|
23
26
|
basename = File.basename(abs_path)
|
|
24
27
|
stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
|
|
25
28
|
next if stem.match?(Key::Grammar::SEGMENT)
|
|
@@ -31,10 +34,13 @@ module Textus
|
|
|
31
34
|
# When the entry uses `index_filename:`, only the parent-directory
|
|
32
35
|
# segments leading to each index file participate in keys. Sibling
|
|
33
36
|
# files and unrelated subtrees are not enumerated and must not be
|
|
34
|
-
# flagged. Each illegal segment is reported once per path.
|
|
35
|
-
|
|
37
|
+
# flagged. Each illegal segment is reported once per path. Paths under
|
|
38
|
+
# an ignored subtree (ADR 0042) are excluded before any segment check.
|
|
39
|
+
def check_index_paths(entry, index_fn, base, out)
|
|
36
40
|
Dir.glob(File.join(base, "**", index_fn)).each do |fp|
|
|
37
41
|
rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
42
|
+
next if entry.ignored?(rel)
|
|
43
|
+
|
|
38
44
|
File.dirname(rel).split("/").reject { |s| s.empty? || s == "." }.each do |seg|
|
|
39
45
|
next if seg.match?(Key::Grammar::SEGMENT)
|
|
40
46
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# .textus/hooks/machine_intake.rb
|
|
2
|
+
# Scaffolded by `textus init` — CUSTOMIZE FREELY, or delete the feeds.machines
|
|
3
|
+
# entry from manifest.yaml if you don't want it.
|
|
4
|
+
# Feeds a per-host SNAPSHOT into feeds.machines.<host> on `textus fetch` (never
|
|
5
|
+
# on the per-turn boot/pulse path). It is NESTED so it grows to a fleet: the
|
|
6
|
+
# `local` leaf scans THIS host; add ssh hosts with the cookbook recipe
|
|
7
|
+
# (docs/cookbook/environment-scan.md). tracked:false → gitignored. Keep this an
|
|
8
|
+
# ALLOWLIST of versions and counts — NEVER secrets, raw `env`, or package lists.
|
|
9
|
+
Textus.hook do |reg|
|
|
10
|
+
reg.on(:resolve_intake, :machines) do |config:, args:, **|
|
|
11
|
+
machine = args[:leaf_segments].first or
|
|
12
|
+
raise "fetch a host leaf, e.g. `textus fetch feeds.machines.local`"
|
|
13
|
+
spec = (config["machines"] || {}).fetch(machine) { raise "unknown machine: #{machine}" }
|
|
14
|
+
unless (spec["via"] || "local").to_s == "local"
|
|
15
|
+
raise "machine #{machine}: only `via: local` is scaffolded — see " \
|
|
16
|
+
"docs/cookbook/environment-scan.md for the SSH (remote) fan-out"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
sh = ->(cmd) { `#{cmd}`.strip } # local shell-out, no network
|
|
20
|
+
ver = ->(cmd) { o = `#{cmd} 2>/dev/null`.strip; o.empty? ? nil : o } # nil if tool absent
|
|
21
|
+
count = ->(cmd) { n = `#{cmd} 2>/dev/null`.strip.lines.size; n.zero? ? nil : n }
|
|
22
|
+
{ content: {
|
|
23
|
+
# git_* describe THIS repo on the control host — only meaningful for `local`.
|
|
24
|
+
"git_head" => sh.call("git rev-parse --short HEAD 2>/dev/null"),
|
|
25
|
+
"git_branch" => sh.call("git rev-parse --abbrev-ref HEAD 2>/dev/null"),
|
|
26
|
+
"git_dirty" => !sh.call("git status --porcelain 2>/dev/null").empty?,
|
|
27
|
+
"repo_root" => sh.call("git rev-parse --show-toplevel 2>/dev/null"),
|
|
28
|
+
"captured_at" => Time.now.utc.iso8601,
|
|
29
|
+
"os" => RbConfig::CONFIG["host_os"],
|
|
30
|
+
"arch" => RbConfig::CONFIG["host_cpu"],
|
|
31
|
+
"ruby_version" => RUBY_VERSION,
|
|
32
|
+
"runtimes" => { # versions only; nil when not installed
|
|
33
|
+
"node" => ver.call("node --version"),
|
|
34
|
+
"python" => ver.call("python3 --version"),
|
|
35
|
+
"go" => ver.call("go version"),
|
|
36
|
+
},
|
|
37
|
+
"packages" => { # COUNTS only — never the list (size/secrets)
|
|
38
|
+
"brew" => count.call("brew list --formula"), # ~1-3s on macOS; runs only on fetch, amortized by the ttl rule
|
|
39
|
+
"apt" => count.call("dpkg-query -f '.\n' -W"),
|
|
40
|
+
},
|
|
41
|
+
"textus_version" => Textus::VERSION,
|
|
42
|
+
"protocol" => Textus::PROTOCOL,
|
|
43
|
+
} }
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
|
+
require "pathname"
|
|
2
3
|
|
|
3
4
|
module Textus
|
|
4
5
|
module Init
|
|
@@ -21,6 +22,26 @@ module Textus
|
|
|
21
22
|
- { key: knowledge.notes, path: knowledge/notes, zone: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
|
|
22
23
|
- { key: notebook.notes, path: notebook/notes, zone: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
|
|
23
24
|
- { key: proposals.notes, path: proposals/notes, zone: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
|
|
25
|
+
# A per-host snapshot, pulled by `textus fetch feeds.machines.local --as=automation`.
|
|
26
|
+
# Nested so it grows to a fleet — add feeds.machines.<host> leaves over SSH
|
|
27
|
+
# (see docs/cookbook/environment-scan.md) without renaming. tracked:false →
|
|
28
|
+
# gitignored (machine info can be sensitive/noisy) but still protocol-readable
|
|
29
|
+
# via `textus get feeds.machines.local`. Delete to opt out. (ADR 0043)
|
|
30
|
+
- key: feeds.machines
|
|
31
|
+
path: feeds/machines
|
|
32
|
+
zone: feeds
|
|
33
|
+
format: yaml
|
|
34
|
+
nested: true
|
|
35
|
+
tracked: false
|
|
36
|
+
kind: intake
|
|
37
|
+
intake:
|
|
38
|
+
handler: machines
|
|
39
|
+
config:
|
|
40
|
+
machines:
|
|
41
|
+
local: { via: local }
|
|
42
|
+
rules:
|
|
43
|
+
- match: feeds.machines.**
|
|
44
|
+
fetch: { ttl: 1h, on_stale: warn } # meaningful on a long-running server
|
|
24
45
|
YAML
|
|
25
46
|
|
|
26
47
|
HOOKS_README = <<~MD
|
|
@@ -91,12 +112,31 @@ module Textus
|
|
|
91
112
|
File.write(File.join(dir, ".gitkeep"), "")
|
|
92
113
|
end
|
|
93
114
|
File.write(File.join(target_root, "hooks", "README.md"), HOOKS_README)
|
|
115
|
+
scaffold_dir = File.expand_path("init/templates", __dir__)
|
|
116
|
+
File.write(File.join(target_root, "hooks", "machine_intake.rb"),
|
|
117
|
+
File.read(File.join(scaffold_dir, "machine_intake.rb")))
|
|
94
118
|
File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
|
|
95
119
|
FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
|
|
96
120
|
FileUtils.mkdir_p(Textus::Layout.state(target_root))
|
|
97
121
|
FileUtils.mkdir_p(Textus::Layout.locks(target_root))
|
|
98
|
-
File.write(File.join(target_root, ".gitignore"),
|
|
122
|
+
File.write(File.join(target_root, ".gitignore"), derived_gitignore(target_root))
|
|
99
123
|
{ "protocol" => PROTOCOL, "initialized" => target_root }
|
|
100
124
|
end
|
|
125
|
+
|
|
126
|
+
# The store's `.gitignore` is generated, never hand-kept (ADR 0038), and now
|
|
127
|
+
# derived from the manifest: the run subtree plus every `tracked: false`
|
|
128
|
+
# entry's resolved path (ADR 0043).
|
|
129
|
+
def self.derived_gitignore(target_root)
|
|
130
|
+
manifest = Textus::Manifest.load(target_root)
|
|
131
|
+
root = Pathname.new(target_root)
|
|
132
|
+
untracked = manifest.data.entries.reject(&:tracked?).map do |e|
|
|
133
|
+
if e.nested? # a whole subtree of leaf files (feeds.machines.* → zones/feeds/machines/)
|
|
134
|
+
"#{File.join("zones", e.path)}/"
|
|
135
|
+
else
|
|
136
|
+
Pathname.new(Textus::Key::Path.resolve(manifest.data, e)).relative_path_from(root).to_s
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
Textus::Layout.gitignore_body(untracked_paths: untracked)
|
|
140
|
+
end
|
|
101
141
|
end
|
|
102
142
|
end
|
data/lib/textus/layout.rb
CHANGED
|
@@ -33,9 +33,22 @@ module Textus
|
|
|
33
33
|
File.join(audit_dir(root), "audit.log")
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
# The store's `.gitignore` body. Always ignores the runtime subtree
|
|
37
|
+
# (`.run/`, ADR 0038); when given untracked entry paths (entries marked
|
|
38
|
+
# `tracked: false`), it also lists those so they stay protocol-readable but
|
|
39
|
+
# uncommitted (ADR 0043, refining 0038). Generated, never hand-kept — no
|
|
40
|
+
# drift between the manifest and the ignore file.
|
|
41
|
+
def self.gitignore_body(untracked_paths: [])
|
|
42
|
+
lines = ["# textus runtime artifacts — safe to delete, never commit",
|
|
43
|
+
"#{RUN}/"]
|
|
44
|
+
unless untracked_paths.empty?
|
|
45
|
+
lines << "# tracked:false entries — protocol-readable, not committed (sensitive)"
|
|
46
|
+
lines.concat(untracked_paths)
|
|
47
|
+
end
|
|
48
|
+
"#{lines.join("\n")}\n"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Back-compat constant: the no-untracked-entries body (just the run subtree).
|
|
52
|
+
GITIGNORE = gitignore_body
|
|
40
53
|
end
|
|
41
54
|
end
|
|
@@ -31,6 +31,12 @@ module Textus
|
|
|
31
31
|
def intake? = false
|
|
32
32
|
def leaf? = false
|
|
33
33
|
|
|
34
|
+
# Whether git should track this entry's file. Default true; an entry
|
|
35
|
+
# marked `tracked: false` in the manifest stays protocol-readable but is
|
|
36
|
+
# listed in the generated `.gitignore` (ADR 0043). Cross-cutting, so it
|
|
37
|
+
# reads from raw here rather than threading through every constructor.
|
|
38
|
+
def tracked? = @raw["tracked"] != false
|
|
39
|
+
|
|
34
40
|
# Nil stubs for cross-cutting optional attrs. Subclasses override the
|
|
35
41
|
# ones they own. Validators and serializers can call these directly
|
|
36
42
|
# without `respond_to?` guards.
|
|
@@ -39,6 +45,11 @@ module Textus
|
|
|
39
45
|
def events = {}
|
|
40
46
|
def publish_each = nil
|
|
41
47
|
def index_filename = nil
|
|
48
|
+
def ignore = []
|
|
49
|
+
|
|
50
|
+
# Per-entry ignore (ADR 0042). Base entries enumerate no tree, so
|
|
51
|
+
# nothing is ever ignored; Nested overrides with real patterns.
|
|
52
|
+
def ignored?(_rel_path) = false
|
|
42
53
|
|
|
43
54
|
# Minimal context object passed into entry `publish_via` hooks.
|
|
44
55
|
# Everything beyond the three primitives is derived. Data.define
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
# Pure glob matcher backing per-entry `ignore:` patterns (ADR 0042).
|
|
5
|
+
# `rel_path` is the slash-joined path of a candidate file relative to the
|
|
6
|
+
# entry's base directory.
|
|
7
|
+
#
|
|
8
|
+
# Matching is segment-wise so the `**` globstar means "zero or more path
|
|
9
|
+
# segments" — `File.fnmatch` alone cannot express this (under
|
|
10
|
+
# FNM_PATHNAME a trailing `**` will not cross a `/`; without it a leading
|
|
11
|
+
# `**/` will not match zero leading segments). So `**/node_modules/**`
|
|
12
|
+
# catches the `node_modules` subtree at any depth, including the store
|
|
13
|
+
# root, and the directory entry itself.
|
|
14
|
+
#
|
|
15
|
+
# Within a single segment, matching delegates to `File.fnmatch` with
|
|
16
|
+
# FNM_EXTGLOB, so a single `*` is anchored to that segment (it does not
|
|
17
|
+
# cross `/`) and `{a,b}` alternation works.
|
|
18
|
+
module IgnoreMatcher
|
|
19
|
+
SEGMENT_FLAGS = File::FNM_EXTGLOB
|
|
20
|
+
|
|
21
|
+
def self.match?(patterns, rel_path)
|
|
22
|
+
path_segs = rel_path.split("/").reject(&:empty?)
|
|
23
|
+
Array(patterns).any? do |pat|
|
|
24
|
+
match_segments(pat.split("/").reject(&:empty?), path_segs)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Classic globstar matcher. `**` matches zero or more whole segments;
|
|
29
|
+
# any other pattern segment matches exactly one path segment via fnmatch.
|
|
30
|
+
def self.match_segments(pat_segs, path_segs)
|
|
31
|
+
return path_segs.empty? if pat_segs.empty?
|
|
32
|
+
|
|
33
|
+
if pat_segs.first == "**"
|
|
34
|
+
match_segments(pat_segs[1..], path_segs) ||
|
|
35
|
+
(!path_segs.empty? && match_segments(pat_segs, path_segs[1..]))
|
|
36
|
+
else
|
|
37
|
+
!path_segs.empty? &&
|
|
38
|
+
File.fnmatch?(pat_segs.first, path_segs.first, SEGMENT_FLAGS) &&
|
|
39
|
+
match_segments(pat_segs[1..], path_segs[1..])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
private_class_method :match_segments
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -5,16 +5,22 @@ module Textus
|
|
|
5
5
|
PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
|
|
6
6
|
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
7
7
|
|
|
8
|
-
attr_reader :index_filename, :publish_each
|
|
8
|
+
attr_reader :index_filename, :publish_each, :ignore
|
|
9
9
|
|
|
10
|
-
def initialize(index_filename: nil, publish_each: nil, **rest)
|
|
10
|
+
def initialize(index_filename: nil, publish_each: nil, ignore: nil, **rest)
|
|
11
11
|
super(**rest)
|
|
12
12
|
@index_filename = index_filename
|
|
13
13
|
@publish_each = publish_each
|
|
14
|
+
@ignore = Array(ignore)
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def nested? = true
|
|
17
18
|
|
|
19
|
+
# True when `rel_path` (slash-joined, relative to the entry base) matches
|
|
20
|
+
# any configured ignore glob. Evaluated ABOVE key-legality (ADR 0042):
|
|
21
|
+
# an ignored path is excluded, never judged.
|
|
22
|
+
def ignored?(rel_path) = IgnoreMatcher.match?(@ignore, rel_path)
|
|
23
|
+
|
|
18
24
|
def publish_target_for(full_key)
|
|
19
25
|
return nil if @publish_each.nil?
|
|
20
26
|
|
|
@@ -65,6 +71,7 @@ module Textus
|
|
|
65
71
|
new(
|
|
66
72
|
index_filename: raw["index_filename"],
|
|
67
73
|
publish_each: raw["publish_each"],
|
|
74
|
+
ignore: raw["ignore"],
|
|
68
75
|
**common,
|
|
69
76
|
)
|
|
70
77
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Validators
|
|
5
|
+
# Validates the per-entry `ignore:` field (ADR 0042): a list of
|
|
6
|
+
# non-empty glob strings, allowed only on nested entries.
|
|
7
|
+
module Ignore
|
|
8
|
+
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
9
|
+
patterns = entry.raw["ignore"]
|
|
10
|
+
return if patterns.nil?
|
|
11
|
+
|
|
12
|
+
raise UsageError.new("entry '#{entry.key}': ignore requires nested: true") unless entry.nested?
|
|
13
|
+
|
|
14
|
+
raise UsageError.new("entry '#{entry.key}': ignore must be a list of glob strings") unless patterns.is_a?(Array)
|
|
15
|
+
|
|
16
|
+
patterns.each do |pat|
|
|
17
|
+
next if pat.is_a?(String) && !pat.empty?
|
|
18
|
+
|
|
19
|
+
raise UsageError.new(
|
|
20
|
+
"entry '#{entry.key}': each ignore pattern must be a non-empty string (got #{pat.inspect})",
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -78,6 +78,8 @@ module Textus
|
|
|
78
78
|
|
|
79
79
|
def nested_row_for(entry, base, path)
|
|
80
80
|
rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
81
|
+
return nil if entry.ignored?(rel)
|
|
82
|
+
|
|
81
83
|
entry_if = entry.index_filename
|
|
82
84
|
stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
83
85
|
segs = stripped.split("/").reject { |s| s.empty? || s == "." }
|
|
@@ -25,7 +25,7 @@ module Textus
|
|
|
25
25
|
ENTRY_KEYS = %w[
|
|
26
26
|
key path zone kind schema owner nested format
|
|
27
27
|
compute template publish_to publish_each
|
|
28
|
-
intake events inject_boot index_filename
|
|
28
|
+
intake events inject_boot index_filename ignore tracked
|
|
29
29
|
].freeze
|
|
30
30
|
COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
|
|
31
31
|
INTAKE_KEYS = %w[handler config].freeze
|
data/lib/textus/version.rb
CHANGED
data/lib/textus.rb
CHANGED
|
@@ -17,6 +17,10 @@ loader.inflector.inflect(
|
|
|
17
17
|
loader.ignore(File.expand_path("textus/errors.rb", __dir__))
|
|
18
18
|
loader.ignore(File.expand_path("textus/mcp.rb", __dir__))
|
|
19
19
|
loader.ignore(File.expand_path("textus/mcp/errors.rb", __dir__))
|
|
20
|
+
# Scaffold sources copied verbatim into user stores by `textus init`. They are
|
|
21
|
+
# file templates (one calls `Textus.hook` at load time), not gem constants —
|
|
22
|
+
# Zeitwerk must not manage or eager-load them (ADR 0043).
|
|
23
|
+
loader.ignore(File.expand_path("textus/init/templates", __dir__))
|
|
20
24
|
loader.setup
|
|
21
25
|
loader.eager_load
|
|
22
26
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: textus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.39.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -103,11 +103,9 @@ executables:
|
|
|
103
103
|
extensions: []
|
|
104
104
|
extra_rdoc_files: []
|
|
105
105
|
files:
|
|
106
|
-
- ARCHITECTURE.md
|
|
107
106
|
- CHANGELOG.md
|
|
108
107
|
- README.md
|
|
109
108
|
- SPEC.md
|
|
110
|
-
- docs/conventions.md
|
|
111
109
|
- exe/textus
|
|
112
110
|
- lib/textus.rb
|
|
113
111
|
- lib/textus/boot.rb
|
|
@@ -235,6 +233,7 @@ files:
|
|
|
235
233
|
- lib/textus/hooks/rpc_registry.rb
|
|
236
234
|
- lib/textus/hooks/signature.rb
|
|
237
235
|
- lib/textus/init.rb
|
|
236
|
+
- lib/textus/init/templates/machine_intake.rb
|
|
238
237
|
- lib/textus/key/distance.rb
|
|
239
238
|
- lib/textus/key/grammar.rb
|
|
240
239
|
- lib/textus/key/path.rb
|
|
@@ -251,6 +250,7 @@ files:
|
|
|
251
250
|
- lib/textus/manifest/entry.rb
|
|
252
251
|
- lib/textus/manifest/entry/base.rb
|
|
253
252
|
- lib/textus/manifest/entry/derived.rb
|
|
253
|
+
- lib/textus/manifest/entry/ignore_matcher.rb
|
|
254
254
|
- lib/textus/manifest/entry/intake.rb
|
|
255
255
|
- lib/textus/manifest/entry/leaf.rb
|
|
256
256
|
- lib/textus/manifest/entry/nested.rb
|
|
@@ -258,6 +258,7 @@ files:
|
|
|
258
258
|
- lib/textus/manifest/entry/validators.rb
|
|
259
259
|
- lib/textus/manifest/entry/validators/events.rb
|
|
260
260
|
- lib/textus/manifest/entry/validators/format_matrix.rb
|
|
261
|
+
- lib/textus/manifest/entry/validators/ignore.rb
|
|
261
262
|
- lib/textus/manifest/entry/validators/index_filename.rb
|
|
262
263
|
- lib/textus/manifest/entry/validators/inject_boot.rb
|
|
263
264
|
- lib/textus/manifest/entry/validators/publish_each.rb
|
data/ARCHITECTURE.md
DELETED
data/docs/conventions.md
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
# Conventions
|
|
2
|
-
|
|
3
|
-
> **Reference** · for integrators · **read when** you're shaping a `.textus/` tree and want the idiomatic choices
|
|
4
|
-
> **SSoT for** idiomatic key naming, schema design, and automation integration · **reviewed** 2026-05 (v0.35)
|
|
5
|
-
|
|
6
|
-
Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and integrating with build automation. The spec ([`../SPEC.md`](../SPEC.md)) defines what's enforceable; this document captures what's *idiomatic*.
|
|
7
|
-
|
|
8
|
-
## Key naming
|
|
9
|
-
|
|
10
|
-
- **Segments are lowercase, kebab- or snake-case.** The grammar `^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$` is the hard limit. Prefer `acme-dashboard` over `acmedashboard` when there's a natural word break.
|
|
11
|
-
- **Lead with the zone in the key path.** `working.projects.acme.dashboard`, not `projects.acme.dashboard`. The zone prefix makes it obvious from the key alone whether a write will be accepted.
|
|
12
|
-
- **Mirror the directory structure.** If `working.projects.acme.dashboard` resolves to `working/projects/acme/dashboard.md`, do not invent shortcuts that diverge.
|
|
13
|
-
- **Don't pluralise the leaf.** `working.network.org.jane`, not `working.network.org.janes`. Pluralise the container, not the entry.
|
|
14
|
-
|
|
15
|
-
## Zone layout
|
|
16
|
-
|
|
17
|
-
Recommended top-level layout — the spec allows alternatives, but this is what tooling will default to:
|
|
18
|
-
|
|
19
|
-
```
|
|
20
|
-
.textus/
|
|
21
|
-
manifest.yaml
|
|
22
|
-
schemas/ # YAML schema definitions
|
|
23
|
-
zones/
|
|
24
|
-
knowledge/ # authored truth: identity, voice, decisions — author-holders write
|
|
25
|
-
notebook/ # agent's own durable lane (workspace) — keep-holders write
|
|
26
|
-
feeds/ # automation-fed external inputs (fetch)
|
|
27
|
-
proposals/ # AI proposals awaiting accept (propose)
|
|
28
|
-
artifacts/ # generated by build automation — never edit by hand
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
Inside `knowledge/`, group by **domain** (identity, people, projects, decisions, runbooks), not by file type or date. `knowledge.identity.*` is the convention for slow-changing identity facts. Inside `artifacts/`, group by **producer** (`artifacts/catalogs/`, `artifacts/indexes/`) so it's clear which build job owns what.
|
|
32
|
-
|
|
33
|
-
## Schema design
|
|
34
|
-
|
|
35
|
-
- **One schema per entry type, not per directory.** `person.yaml`, `project.yaml`, `decision.yaml` — applied across multiple subtrees if the shape matches.
|
|
36
|
-
- **Required = "this entry is meaningless without it."** Everything else is `optional`. Resist the urge to mark organisational metadata (like `tags`) required.
|
|
37
|
-
- **Prefer `enum` over free-text** for low-cardinality fields (relationship type, status, severity). Agents are far better at picking from a list than at producing exact strings.
|
|
38
|
-
- **Cap string lengths** with `max:` where the field has a natural bound (names, summaries). Skip for prose body — bodies are not schema-validated, only frontmatter is.
|
|
39
|
-
|
|
40
|
-
## Owner strings
|
|
41
|
-
|
|
42
|
-
The `owner:` field in the manifest is **advisory metadata**, not an ACL. Use it to label *who's expected to write here*:
|
|
43
|
-
|
|
44
|
-
- `textus:network` — humans curate
|
|
45
|
-
- `agent:planner` — a specific named agent
|
|
46
|
-
- `build:catalog-skills` — a specific build job
|
|
47
|
-
|
|
48
|
-
Tooling around `git blame` or audit logs may filter on owner; the gem itself only echoes it back in envelopes.
|
|
49
|
-
|
|
50
|
-
## Derived entries
|
|
51
|
-
|
|
52
|
-
A derived entry declares a `compute:` block with a `kind:` discriminator. Two kinds:
|
|
53
|
-
|
|
54
|
-
**`compute: { kind: projection }`** — textus computes the entry on `textus build` from other store entries. Declarative; nothing shells out.
|
|
55
|
-
|
|
56
|
-
```yaml
|
|
57
|
-
- key: artifacts.catalogs.people
|
|
58
|
-
path: artifacts/catalogs/people.md
|
|
59
|
-
zone: artifacts
|
|
60
|
-
schema: null
|
|
61
|
-
owner: build:catalog-people
|
|
62
|
-
compute:
|
|
63
|
-
kind: projection
|
|
64
|
-
select: knowledge.network.org # prefix or list of prefixes
|
|
65
|
-
pluck: [name, relationship, org]
|
|
66
|
-
sort_by: name
|
|
67
|
-
template: people.mustache # under .textus/templates/
|
|
68
|
-
publish_to: [docs/people.md] # optional repo-relative byte-copy targets
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
**`compute: { kind: external }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `textus freshness` can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `build` (default: `automation`).
|
|
72
|
-
|
|
73
|
-
```yaml
|
|
74
|
-
- key: artifacts.catalogs.skills
|
|
75
|
-
path: artifacts/catalogs/skills.md
|
|
76
|
-
zone: artifacts
|
|
77
|
-
owner: build:catalog-skills
|
|
78
|
-
compute:
|
|
79
|
-
kind: external
|
|
80
|
-
command: "rake catalog:skills" # informational; the automation invokes it
|
|
81
|
-
sources: [knowledge.projects, knowledge.network]
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `compute.sources` — same list, recorded twice so a diff proves what was consumed.
|
|
85
|
-
|
|
86
|
-
Full contract for both shapes is in [`../SPEC.md` §5.2.1 and §5.2.2](../SPEC.md). Transforms (`compute.transform:`) and per-leaf publishing (`publish_each:`) are also covered there.
|
|
87
|
-
|
|
88
|
-
## Intake and freshness
|
|
89
|
-
|
|
90
|
-
External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler; fetch is on demand:
|
|
91
|
-
|
|
92
|
-
```sh
|
|
93
|
-
textus fetch feeds.notion.roadmap --as=automation
|
|
94
|
-
textus fetch stale --zone=feeds --as=automation # everything past its TTL
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
Freshness budgets live in the top-level `rules:` block, matched by glob:
|
|
98
|
-
|
|
99
|
-
```yaml
|
|
100
|
-
rules:
|
|
101
|
-
- match: feeds.notion.**
|
|
102
|
-
fetch: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
A typical scheduled-fetch integration shells the `fetch stale` sweep itself:
|
|
106
|
-
|
|
107
|
-
```sh
|
|
108
|
-
textus fetch stale --zone=intake --as=automation # in cron / CI
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
See [`./zones.md` §6](zones.md) for the full intake contract and [`./events.md`](events.md) for writing custom handlers.
|
|
112
|
-
|
|
113
|
-
### Read vs. fetch
|
|
114
|
-
|
|
115
|
-
There are two read operations, and the difference matters in custom code:
|
|
116
|
-
|
|
117
|
-
| Operation | Triggers fetch? | Use for |
|
|
118
|
-
|-----------|-----------------|---------|
|
|
119
|
-
| `ops.get` | No — pure read of on-disk envelope + freshness verdict | build / materialization paths, schema tooling, any context where a side-effecting read would surprise the caller |
|
|
120
|
-
| `ops.get_or_fetch` | Yes — composes `get` with the orchestrator per the rule's `on_stale` policy | interactive reads (`textus get`, dashboards) where the caller wants the freshest envelope obtainable |
|
|
121
|
-
|
|
122
|
-
Build always uses the pure path; injecting fetch into materialization caused the cascading-staleness incident behind issue #59. Pick `get_or_fetch` only when you genuinely want side effects on read.
|
|
123
|
-
|
|
124
|
-
## Body content
|
|
125
|
-
|
|
126
|
-
- **Bodies are Markdown.** Headings, lists, code fences — whatever a human or agent finds useful.
|
|
127
|
-
- **The schema does not validate the body.** If a field belongs in structured data, put it in frontmatter, not the body.
|
|
128
|
-
- **Keep entries short.** If a project entry hits 500 lines, it probably wants to be split into sub-entries (e.g. `working.projects.acme.dashboard` + `working.projects.acme.api`) rather than one mega-document.
|
|
129
|
-
|
|
130
|
-
## Concurrency
|
|
131
|
-
|
|
132
|
-
For multi-writer environments, **always pass `if_etag`** on `put`. The gem treats etag-less writes as last-writer-wins on purpose (single-writer scripts, fresh-file creation), but anything resembling a daemon or a long-running agent should round-trip the etag.
|
|
133
|
-
|
|
134
|
-
## Application layering
|
|
135
|
-
|
|
136
|
-
The application layer is organised around three shapes — `Manifest` as a composition record, a single `Container` capability record handed to every use case, and a split envelope reader/writer. See [ADR 0018](architecture/decisions/0018-manifest-carving.md), [ADR 0017](architecture/decisions/0017-envelope-io-split.md), [ADR 0022](architecture/decisions/0022-container-call-dispatcher.md), and [ADR 0023](architecture/decisions/0023-uniform-use-case-shape.md).
|
|
137
|
-
|
|
138
|
-
- **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(zone)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
|
|
139
|
-
- **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/{read,write,maintenance}/` with `def initialize(container:, call:)` and a `#call(...)` method; verbs are looked up in the static `Textus::Dispatcher::VERBS` table. `Container` is a `Data.define` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc`, `authorizer`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). A use case that emits events derives its `Hooks::Context` from `(container, call)` — nothing is injected. Use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer. `store.as(role)` returns a `RoleScope` that forwards verbs to the dispatcher.
|
|
140
|
-
- **Write path is split**: `Envelope::IO::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Envelope::IO::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
|
|
141
|
-
|
|
142
|
-
The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/3`) are unchanged.
|
|
143
|
-
|
|
144
|
-
## Pairing with other tools
|
|
145
|
-
|
|
146
|
-
- **MCP servers**: a thin server that exposes `textus get` and `textus put` as tools is the recommended way to give Claude/agents access. Don't bake MCP into this gem.
|
|
147
|
-
- **Vector stores**: index `body` content into a vector store if you want fuzzy retrieval. `frontmatter` stays in textus as the source of truth for deterministic facts.
|
|
148
|
-
- **CI**: run `textus freshness` (or `textus list` + schema validation) in CI to catch drift between derived entries and their sources.
|