textus 0.38.0 → 0.39.0

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: 020c9cf77e098bd7099a5cd0871801b40d7be8266b2942c8ddb6cfed60c85af0
4
- data.tar.gz: d4bfacdf7f6049df88e37b53e24ff4927f4bfd20f3d0ff55d5ed4d54ecbfb1fd
3
+ metadata.gz: 15b2e8bcfb3e83425617ee187705b88ad99c1f96671a7ad04a8394073f98065d
4
+ data.tar.gz: e0711f287c8739fcc2e5f9507fd5637b9c63159b2d8e364f9d65f338138ff432
5
5
  SHA512:
6
- metadata.gz: aefe80bba5a38c4db29fe663cf3f41c77229075cc5b02b95b9fac0c8df62b81aed936bef95dd610becb6c700238936ddd8dd528762dae576747552ab4e9acbac
7
- data.tar.gz: 6d342cad8cb4dd0d3f320990c45ca0c4a996e5499dafd9c90f72b53731d53294f57b8dea2b4c625d96e43245da2138f3df8971d9ba416940cdc59a390754f577
6
+ metadata.gz: 74ec9edba22fdd7884c7bf1a16b9f73a636524c11f889824a516775857294ea674ebd6f6fe9b0a4d98028d30cf5673b51c376760968baa4d8413c83a33475484
7
+ data.tar.gz: c097ccd942039828754bd70ae2f457e172ca88166c318c72199138e126301aa4432cec318ff77c456a73de4176c0e6fe65160cca899111dcd27785adc9eff62c
data/CHANGELOG.md CHANGED
@@ -11,6 +11,31 @@ tracks both additive improvements and breaking protocol bumps independently.
11
11
 
12
12
  ## Unreleased
13
13
 
14
+ ## 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))
15
+
16
+ No `textus/3` wire-format change. Manifest schema gains one optional, backward-compatible key (`ignore:`); existing manifests are unaffected.
17
+
18
+ ### Added
19
+
20
+ - **Per-entry `ignore:` globs on `nested` entries (ADR 0042).** A `nested`
21
+ entry may declare a list of gitignore-style globs (e.g.
22
+ `["**/node_modules/**", "**/dist/**"]`) to keep vendored or generated
23
+ subtrees out of the store. Patterns are honoured by **one shared filter
24
+ seam** consulted by both resolver enumeration (`list`, `build`) and
25
+ `textus doctor`, evaluated *above* key-legality: an ignored path is excluded,
26
+ never judged. This closes the prior divergence where a store could `list`
27
+ cleanly while `doctor` was red on the same vendored paths. Matching is
28
+ segment-wise globstar — `**` spans zero or more path segments; within a
29
+ segment `*` is anchored and `{a,b}` alternates (stdlib `File.fnmatch`,
30
+ no new dependency). Documented in
31
+ [`docs/reference/zones.md`](docs/reference/zones.md#nested-entries).
32
+
33
+ ### Internal
34
+
35
+ - **Dogfood textus in its own repo ([ADR 0041](docs/architecture/decisions/0041-dogfood-textus-in-its-own-repo.md)).**
36
+ A self-development store and MCP wiring for textus's own repository. No change
37
+ to the published gem's behavior.
38
+
14
39
  ## 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
40
 
16
41
  No `textus/3` wire-format change; no manifest-schema change.
@@ -451,7 +476,7 @@ Protocol remains `textus/3`.
451
476
  `refresh`, `refresh_stale`, `schema`, `rules`). Session state (cursor,
452
477
  role, manifest_etag) held server-side. Manifest drift surfaces as
453
478
  `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).
479
+ See [`docs/reference/mcp.md`](docs/reference/mcp.md) and [ADR 0015](docs/architecture/decisions/0015-agent-gate-mcp.md).
455
480
  - `examples/claude-plugin/.mcp.json` and migrated skills/commands/agents —
456
481
  zero `textus <verb>` shell strings remain in plugin markdown.
457
482
 
data/README.md CHANGED
@@ -30,13 +30,35 @@ flowchart LR
30
30
  human(["human"]) -->|author| knowledge["knowledge<br/>(canon)"]
31
31
  agent(["agent"]) -->|keep| notebook["notebook<br/>(workspace)"]
32
32
  agent -->|propose| proposals["proposals<br/>(queue)"]
33
- proposals -->|human accepts| knowledge
33
+ proposals ==>|human accept| knowledge
34
34
  automation(["automation"]) -->|fetch| feeds["feeds<br/>(quarantine)"]
35
35
  automation -->|build| artifacts["artifacts<br/>(derived)"]
36
+ feeds -.->|projection source| artifacts
37
+ knowledge -.->|projection source| artifacts
38
+
39
+ classDef anchor fill:#1f6feb,stroke:#1f6feb,color:#fff;
40
+ class knowledge anchor;
36
41
  ```
37
42
 
38
43
  *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`).*
39
44
 
45
+ 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.
46
+
47
+ ```
48
+ LOW TRUST HIGH TRUST
49
+ (unreviewed) (authoritative)
50
+ ┌──────────────────────────┬───────────────────────────────┐
51
+ DURABLE │ notebook │ knowledge ★ the goal │
52
+ (kept) │ agent's working truth │ canon — a human authors │
53
+ │ durable, but low-trust │ here · the context you ship │
54
+ ├──────────────────────────┼───────────────────────────────┤
55
+ TRANSIENT │ feeds │ proposals (queue) │
56
+ (staging) │ raw external input, │ a candidate, in review │
57
+ │ unverified │ ▲ climbs via human accept │
58
+ └──────────────────────────┴───────────────────────────────┘
59
+ raw material ──── propose ────► a human accept lifts it to canon
60
+ ```
61
+
40
62
  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
63
 
42
64
  ```
@@ -68,7 +90,7 @@ Try the gate the other way (`textus put knowledge.notes.X --as=agent`) and you g
68
90
  ## Try it
69
91
 
70
92
  - **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)
93
+ - **Wire textus into Claude Code via MCP** — 4 steps, ~5 minutes: [`docs/how-to/agents-mcp.md`](docs/how-to/agents-mcp.md)
72
94
 
73
95
  ## Protocol, not just a gem
74
96
 
@@ -150,7 +172,7 @@ For a worked store — knowledge entries, a staged proposal, schemas, a template
150
172
  - **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
173
  - **Stable identity.** Auto-minted `uid:` survives writes and `textus key mv`; reorganising never breaks references.
152
174
  - **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))
175
+ - **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
176
  - **`textus doctor`.** Health checks across schemas, hooks, keys, sentinels, and the audit log.
155
177
 
156
178
  ## CLI and zones
@@ -158,7 +180,7 @@ For a worked store — knowledge entries, a staged proposal, schemas, a template
158
180
  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
181
 
160
182
  - 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 a tutorial expansion in [`docs/zones.md`](docs/zones.md).
183
+ - 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
184
 
163
185
  `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
186
 
@@ -213,7 +235,7 @@ See SPEC.md §5.10 for the full hook contract.
213
235
 
214
236
  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
237
 
216
- See [`docs/agents-mcp.md`](docs/agents-mcp.md) for the agent boot → pulse loop.
238
+ See [`docs/how-to/agents-mcp.md`](docs/how-to/agents-mcp.md) for the agent boot → pulse loop.
217
239
 
218
240
  ## Examples
219
241
 
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/events.md`](docs/events.md).
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
- def check_index_paths(_entry, index_fn, base, out)
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
 
@@ -39,6 +39,11 @@ module Textus
39
39
  def events = {}
40
40
  def publish_each = nil
41
41
  def index_filename = nil
42
+ def ignore = []
43
+
44
+ # Per-entry ignore (ADR 0042). Base entries enumerate no tree, so
45
+ # nothing is ever ignored; Nested overrides with real patterns.
46
+ def ignored?(_rel_path) = false
42
47
 
43
48
  # Minimal context object passed into entry `publish_via` hooks.
44
49
  # 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
@@ -7,6 +7,7 @@ module Textus
7
7
  PublishEach,
8
8
  InjectBoot,
9
9
  IndexFilename,
10
+ Ignore,
10
11
  FormatMatrix,
11
12
  ].freeze
12
13
 
@@ -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
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
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.38.0"
2
+ VERSION = "0.39.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
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.38.0
4
+ version: 0.39.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -107,7 +107,6 @@ files:
107
107
  - CHANGELOG.md
108
108
  - README.md
109
109
  - SPEC.md
110
- - docs/conventions.md
111
110
  - exe/textus
112
111
  - lib/textus.rb
113
112
  - lib/textus/boot.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/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.