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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 020c9cf77e098bd7099a5cd0871801b40d7be8266b2942c8ddb6cfed60c85af0
4
- data.tar.gz: d4bfacdf7f6049df88e37b53e24ff4927f4bfd20f3d0ff55d5ed4d54ecbfb1fd
3
+ metadata.gz: af15d8a77f0d71c3fab21dc246545b3a7b84242361c518b1d901c491c63e55c4
4
+ data.tar.gz: 4644479ed6df331a6973806fed302b29446d3806d1793d8ea9d9fa661000e986
5
5
  SHA512:
6
- metadata.gz: aefe80bba5a38c4db29fe663cf3f41c77229075cc5b02b95b9fac0c8df62b81aed936bef95dd610becb6c700238936ddd8dd528762dae576747552ab4e9acbac
7
- data.tar.gz: 6d342cad8cb4dd0d3f320990c45ca0c4a996e5499dafd9c90f72b53731d53294f57b8dea2b4c625d96e43245da2138f3df8971d9ba416940cdc59a390754f577
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
- human(["human"]) -->|author| knowledge["knowledge<br/>(canon)"]
31
- agent(["agent"]) -->|keep| notebook["notebook<br/>(workspace)"]
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
- proposals -->|human accepts| knowledge
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 a tutorial expansion in [`docs/zones.md`](docs/zones.md).
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/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
 
@@ -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"), Textus::Layout::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
- GITIGNORE = <<~GITIGNORE
37
- # textus runtime artifacts safe to delete, never commit
38
- #{RUN}/
39
- GITIGNORE
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
@@ -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 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
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.38.0"
2
+ VERSION = "0.39.1"
3
3
  PROTOCOL = "textus/3"
4
4
  end
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.38.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
@@ -1,3 +0,0 @@
1
- # Architecture
2
-
3
- Moved to [`docs/architecture/README.md`](docs/architecture/README.md).
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.