textus 0.39.0 → 0.40.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: 15b2e8bcfb3e83425617ee187705b88ad99c1f96671a7ad04a8394073f98065d
4
- data.tar.gz: e0711f287c8739fcc2e5f9507fd5637b9c63159b2d8e364f9d65f338138ff432
3
+ metadata.gz: 96c684d8a670835fb687a321abb85838ec831e95e8672b7f03453266e2224b65
4
+ data.tar.gz: d9332836814f977824413a7bc379666cfd830f182d2c44a9ed5a71d1e7336513
5
5
  SHA512:
6
- metadata.gz: 74ec9edba22fdd7884c7bf1a16b9f73a636524c11f889824a516775857294ea674ebd6f6fe9b0a4d98028d30cf5673b51c376760968baa4d8413c83a33475484
7
- data.tar.gz: c097ccd942039828754bd70ae2f457e172ca88166c318c72199138e126301aa4432cec318ff77c456a73de4176c0e6fe65160cca899111dcd27785adc9eff62c
6
+ metadata.gz: cbfdd6527a26f2e8fb97cdde2cc99c5df4d36839af1f426bf52156335187d4b48ecbe45af1b271031861bc225d100676522082da6f74c171555bd5cf7bd0b227
7
+ data.tar.gz: 38ac5fff2e9209c8bbbc15745a1ddbd840736ddcc23363c6a61099926b79807a9ccb49630a98b69f6ed41cf2a15d3272bd586f6bc749ea381e4d24158eeb597a
data/CHANGELOG.md CHANGED
@@ -11,6 +11,58 @@ tracks both additive improvements and breaking protocol bumps independently.
11
11
 
12
12
  ## Unreleased
13
13
 
14
+ ## 0.40.0 — 2026-06-02 — `publish_each` owns multi-file leaf subtrees ([ADR 0046](docs/architecture/decisions/0046-publish-leaf-subtrees.md))
15
+
16
+ No `textus/3` wire-format change — publish is repo-local materialization. The
17
+ publish *unit* is derived from the entry's existing `index_filename`, not a new
18
+ manifest key (SPEC §4, §5.3).
19
+
20
+ ### Added
21
+
22
+ - **`publish_each` copies a leaf's whole subtree when the entry declares `index_filename` ([ADR 0046](docs/architecture/decisions/0046-publish-leaf-subtrees.md)).** textus can now own a prose-heavy, multi-file artifact (e.g. a Claude Code Agent Skill: `SKILL.md` + `commands.md` + `references/*`) as a single addressable unit. An entry *without* `index_filename` still publishes one file per leaf (unchanged); an entry *with* `index_filename` treats each leaf as a directory and copies the whole subtree — ignore-filtered (ADR 0042), preserving in-leaf layout, one sentinel per file. Siblings ride along at publish time only: they are never enumerated, addressable, or proposable (the key↔path bijection is preserved). On rebuild, managed orphans under a leaf are pruned (file + sentinel); **unmanaged files are never deleted**. The build envelope grows a `pruned` array.
23
+ - **`doctor` flags orphaned publish targets.** A new `orphaned_publish_targets` check reports a published file whose recorded source no longer exists in the store (a renamed or removed whole leaf) — drift that per-entry `build` won't revisit.
24
+
25
+ ### Changed
26
+
27
+ - **BREAKING (pre-1.0): directory-leaf `publish_each` semantics ([ADR 0046](docs/architecture/decisions/0046-publish-leaf-subtrees.md)).** An entry with `index_filename` + `publish_each` whose leaf directories contain siblings previously published *only the index file*; it now publishes the whole subtree. This breaks no correct usage — it is either the dropped-siblings defect this fixes, or a template that named a file rather than a directory, which the validator now **rejects loudly** at manifest load. **Migration:** a directory-leaf `publish_each` template must name the target *directory* — drop a trailing index filename (`.../{leaf}/SKILL.md` → `.../{leaf}`) or any file extension (`.../{leaf}.md` → `.../{leaf}`), and use `{leaf}` or `{key}` (not `{basename}`/`{ext}`, which are file-only).
28
+ - **Role names are now a closed set `{human, agent, automation}` ([ADR 0045](docs/architecture/decisions/0045-close-role-name-set.md)).** A manifest declaring any other role name is rejected at load with `unknown role name '<x>' (allowed: human, agent, automation)`. `Role::NAMES` is the single source of truth; each role's `can:` capabilities remain fully tunable. Principal multiplicity moves to the `owner:` field (`owner: human:patrick`). Stricter `textus/3` validation in `Schema.validate_roles!` — no protocol bump; all shipped manifests already comply.
29
+ - **Hook event tables consolidated into `Textus::Hooks::Catalog` (single source
30
+ of truth).** `EventBus` and `RpcRegistry` no longer keep their own event
31
+ tables; both registries, the Loader DSL router, and internal consumers read
32
+ `Catalog::PUBSUB` / `Catalog::RPC` directly. This removes the drift-prone
33
+ `EventBus::RPC_EVENTS` literal that could silently fall out of sync with
34
+ `RpcRegistry`'s events and weaken the cross-registry guards. Internal
35
+ refactor — no `textus/3` wire-format change.
36
+
37
+ ## 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))
38
+
39
+ No `textus/3` wire-format change. `textus init` scaffolds an additional
40
+ `nested` feed entry; core intake still makes no implicit network calls
41
+ (SPEC §5.4).
42
+
43
+ ### Added
44
+
45
+ - **`textus init` scaffolds `feeds.machines.*` with a local env snapshot
46
+ (ADR 0043).** Generated stores get a `nested` feed entry capturing ambient
47
+ machine context (git HEAD/branch/dirty state, `now`, versions) as an explicit,
48
+ user-owned snapshot — keeping ambient state out of `boot`/`pulse` (which stay
49
+ side-effect-free per ADR 0037) and out of `quarantine` (which means external
50
+ bytes pending validation, where the freshness model does not apply).
51
+
52
+ ### Documentation
53
+
54
+ - **Multi-machine environment-scan cookbook recipe** demonstrating the nested
55
+ `feeds.machines.*` pattern.
56
+ - **Examples** updated to use the `feeds.machine` env snapshot, matching
57
+ `textus init` output.
58
+ - **README flow diagram** redesigned to group writers and colour-code roles.
59
+ - **How-to fixes** for zone-rename drift in the agents-mcp guide and the
60
+ `:publish` event name.
61
+
62
+ ### Internal
63
+
64
+ - Removed the legacy `ARCHITECTURE.md` redirect stub.
65
+
14
66
  ## 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
67
 
16
68
  No `textus/3` wire-format change. Manifest schema gains one optional, backward-compatible key (`ignore:`); existing manifests are unaffected.
data/README.md CHANGED
@@ -27,20 +27,33 @@ 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 accept| 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
36
44
  feeds -.->|projection source| artifacts
37
45
  knowledge -.->|projection source| artifacts
38
46
 
39
- classDef anchor fill:#1f6feb,stroke:#1f6feb,color:#fff;
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;
40
52
  class knowledge anchor;
41
53
  ```
42
54
 
43
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`).*
44
57
 
45
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.
46
59
 
data/SPEC.md CHANGED
@@ -242,7 +242,7 @@ For `nested: true`, the recursive glob matches the format's extension (markdown
242
242
 
243
243
  A file at `.textus/zones/skills/ask/SKILL.md` enumerates as `skills.ask`; `.textus/zones/skills/ask/references/algorithm.md` is not enumerated. Resolving `skills.ask` returns the `SKILL.md` path. `index_filename:` requires `nested: true`; the value must be a bare basename (no slashes).
244
244
 
245
- **Per-leaf publishing (`publish_each:`).** A nested manifest entry MAY declare `publish_each:` to byte-copy every leaf to a templated repo-relative path. `publish_each:` and `publish_to:` are mutually exclusive on the same entry, and `publish_each:` requires `nested: true`. The template substitutes these variables (using `{name}` syntax):
245
+ **Per-leaf publishing (`publish_each:`).** A nested manifest entry MAY declare `publish_each:` to copy every leaf to a templated repo-relative path. `publish_each:` and `publish_to:` are mutually exclusive on the same entry, and `publish_each:` requires `nested: true`. The template substitutes these variables (using `{name}` syntax):
246
246
 
247
247
  | Variable | Value |
248
248
  |--------------|----------------------------------------------------------------------------------------|
@@ -251,7 +251,15 @@ A file at `.textus/zones/skills/ask/SKILL.md` enumerates as `skills.ask`; `.text
251
251
  | `{key}` | Full dotted key. |
252
252
  | `{ext}` | Primary extension for the entry's format, without the leading dot (`md`/`json`/`yaml`/`txt`). |
253
253
 
254
- Validation at manifest load: any unknown variable raises `UsageError`; the template MUST reference at least one of `{leaf}`, `{basename}`, `{key}` (otherwise every leaf would clobber the same target). A computed target outside the repo root is refused at build time with `PublishError`. Example:
254
+ The publish *unit* is derived from the entry's shape, which `index_filename:` already declares (ADR 0046) there is no separate tree-vs-file key:
255
+
256
+ - **Entry *without* `index_filename`** — a leaf is a *file*. `publish_each:` names a file target and one file is byte-copied per leaf.
257
+ - **Entry *with* `index_filename`** — a leaf is a *directory*. `publish_each:` names a directory target, and the **whole leaf subtree** is copied: every file under the leaf directory (the index plus its siblings) is byte-copied to `<target_dir>/<path-relative-to-leaf>`, preserving in-leaf layout. Siblings are opaque payload — they are never enumerated, addressable, or proposable; only the index is a key. The entry's `ignore:` globs (§4, ADR 0042) filter the copied set. Each copied file gets its own sentinel. On rebuild, files under the target that are textus-managed but no longer produced by the current source are **pruned** (file + sentinel); files with no sentinel are never deleted.
258
+
259
+ Validation at manifest load: any unknown variable raises `UsageError`; a computed target outside the repo root is refused at build time with `PublishError`. The discriminator requirement depends on the leaf shape:
260
+
261
+ - **File-leaf** templates MUST reference at least one of `{leaf}`, `{basename}`, `{key}` (otherwise every leaf would clobber the same target).
262
+ - **Directory-leaf** templates (entry has `index_filename`) MUST reference `{leaf}` or `{key}`, and MUST NOT use `{basename}` or `{ext}` (file-only). Because the target is a directory, the template's final segment MUST NOT be the `index_filename` (the `.../{leaf}/SKILL.md` footgun — it would copy the subtree into a directory literally named `SKILL.md/`) and MUST NOT carry a file extension (e.g. `.../{leaf}.md`); both are rejected at load with a fix-the-template message.
255
263
 
256
264
  ```yaml
257
265
  - key: working.skills
@@ -259,10 +267,12 @@ Validation at manifest load: any unknown variable raises `UsageError`; the templ
259
267
  zone: working
260
268
  schema: skill
261
269
  nested: true
262
- publish_each: "skills/{basename}/SKILL.md"
270
+ index_filename: SKILL.md
271
+ publish_each: "skills/{leaf}"
272
+ ignore: ["*.tmp", ".DS_Store"]
263
273
  ```
264
274
 
265
- A leaf at `working.skills.writing.voice-writer` (authored at `.textus/zones/working/skills/writing/voice-writer.md`) publishes to `skills/voice-writer/SKILL.md`.
275
+ A leaf at `working.skills.voice-writer` (a directory `.textus/zones/working/skills/voice-writer/` containing `SKILL.md`, `commands.md`, and `references/*`) publishes its whole subtree under `skills/voice-writer/`, preserving layout.
266
276
 
267
277
  **`inject_boot:`.** A derived entry with a `template:` MAY declare `inject_boot: true`. When `textus build` materializes the entry, it merges the `textus boot` envelope (§9) into the projection data under the key `boot`, so the template can render orientation content (zones, write flows, CLI catalog) alongside its projected rows. The flag is rejected at manifest load on (a) non-derived entries or (b) derived entries without a `template:` — agents reading the rendered file should be able to trust the preamble was produced by the same source of truth `textus boot` exposes.
268
278
 
@@ -468,7 +478,7 @@ When the entry is recomputed, textus copies the in-store file byte-for-byte to e
468
478
 
469
479
  A sentinel is written for each published file at `<store_root>/sentinels/<target-relative-to-repo>.textus-managed.json`, recording `source`, `target`, the target's sha256, and `mode: "copy"`. Sentinels live under the store rather than beside the consumer file so target directories stay clean. The sentinel exists so out-of-band edits can be detected on the next publish — textus refuses to clobber a destination that is not either missing or marked as managed. Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
470
480
 
471
- **Per-leaf publishing.** A nested entry MAY declare `publish_each:` instead of `publish_to:` (see §4). When the build runs, every leaf reachable under the nested entry is byte-copied to the path produced by substituting `{leaf}` / `{basename}` / `{key}` / `{ext}` in the template, with a sentinel written under `<store_root>/sentinels/` at the mirrored target path. The build envelope grows a `published_leaves` array — one row per leaf, with `key`, `source`, and `target` — alongside the existing `built` array. Targets that would resolve outside the repo root are refused.
481
+ **Per-leaf publishing.** A nested entry MAY declare `publish_each:` instead of `publish_to:` (see §4). When the build runs, every leaf reachable under the nested entry is copied to the path produced by substituting `{leaf}` / `{basename}` / `{key}` / `{ext}` in the template, with a sentinel written under `<store_root>/sentinels/` at the mirrored target path. The publish unit follows the entry's `index_filename` (ADR 0046): a file-leaf entry byte-copies one file per leaf; a directory-leaf entry (with `index_filename`) byte-copies the leaf's whole subtree — one row and one sentinel **per file** — applying the entry's `ignore:` filter, and prunes textus-managed files under the target that the current source no longer produces (never touching unmanaged files). The build envelope grows a `published_leaves` array — one row per published file, with `key`, `source`, and `target` — alongside the existing `built` array, plus a `pruned` array listing any orphaned managed files removed on this build. Targets that would resolve outside the repo root are refused.
472
482
 
473
483
  ### 5.4 Intake (declared, fetched via registered intake handler)
474
484
 
data/lib/textus/boot.rb CHANGED
@@ -236,10 +236,10 @@ module Textus
236
236
 
237
237
  def self.hooks_for_container_internal(rpc:, events:)
238
238
  sections = {}
239
- Hooks::RpcRegistry::EVENTS.each_key do |event|
239
+ Hooks::Catalog::RPC.each_key do |event|
240
240
  sections[event.to_s] = rpc.names(event).map(&:to_s).sort
241
241
  end
242
- Hooks::EventBus::EVENTS.each_key do |event|
242
+ Hooks::Catalog::PUBSUB.each_key do |event|
243
243
  sections[event.to_s] = events.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
244
244
  end
245
245
  sections
@@ -7,8 +7,12 @@ module Textus
7
7
  option :prefix, "--prefix=K"
8
8
 
9
9
  def call(store)
10
+ role = store.manifest.policy.actor_for("build") or
11
+ raise UsageError.new(
12
+ "no role holds the 'build' capability",
13
+ hint: "declare a role with `can: [build]` in .textus/manifest.yaml",
14
+ )
10
15
  Textus::Ports::BuildLock.with(root: store.root) do
11
- role = store.manifest.policy.roles_with_capability("build").first || "automation"
12
16
  ops = store.as(role)
13
17
  result = ops.publish(prefix: prefix)
14
18
  emit(result)
@@ -16,12 +16,12 @@ module Textus
16
16
  end
17
17
 
18
18
  rows = []
19
- Textus::Hooks::RpcRegistry::EVENTS.each_key do |event|
19
+ Textus::Hooks::Catalog::RPC.each_key do |event|
20
20
  store.rpc.names(event).each do |name|
21
21
  rows << { "event" => event.to_s, "mode" => "rpc", "name" => name.to_s }
22
22
  end
23
23
  end
24
- Textus::Hooks::EventBus::EVENTS.each_key do |event|
24
+ Textus::Hooks::Catalog::PUBSUB.each_key do |event|
25
25
  store.events.pubsub_handlers(event).each do |h|
26
26
  row = { "event" => event.to_s, "mode" => "pubsub", "name" => h[:name].to_s }
27
27
  row["keys"] = Array(h[:keys]) if h[:keys]
@@ -35,7 +35,7 @@ module Textus
35
35
 
36
36
  rows << {
37
37
  "event" => evt.to_s, "mode" => "manifest", "exec" => defn["exec"],
38
- "key" => e.key, "as" => defn["as"] || "automation"
38
+ "key" => e.key, "as" => defn["as"] || Textus::Role::AUTOMATION
39
39
  }
40
40
  end
41
41
  end
@@ -0,0 +1,35 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # Flags published files whose recorded source no longer exists in the
5
+ # store. Per-leaf prune (ADR 0046) reconciles within a still-present
6
+ # leaf; a renamed or removed *whole* leaf orphans its entire target
7
+ # directory, which a per-entry build won't revisit. This check catches
8
+ # that drift without making `build` scan globally.
9
+ class OrphanedPublishTargets < Check
10
+ def call
11
+ sdir = File.join(root, Textus::Ports::SentinelStore::DIR)
12
+ return [] unless File.directory?(sdir)
13
+
14
+ repo_root = File.dirname(root)
15
+ store = Textus::Ports::SentinelStore.new
16
+ glob = File.join(sdir, "**", "*#{Textus::Ports::SentinelStore::SUFFIX}")
17
+ Dir.glob(glob).filter_map do |spath|
18
+ sentinel = store.load(spath, repo_root)
19
+ next nil if sentinel.nil? || sentinel.source.nil?
20
+ next nil if File.exist?(sentinel.source)
21
+
22
+ {
23
+ "code" => "publish.orphaned_target",
24
+ "level" => "warning",
25
+ "subject" => sentinel.target,
26
+ "message" => "published file #{sentinel.target} has no source in the store " \
27
+ "(recorded source #{sentinel.source} is gone) — likely a renamed or removed leaf",
28
+ "fix" => "remove the stale copy and its sentinel: rm '#{sentinel.target}' '#{spath}'",
29
+ }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
data/lib/textus/doctor.rb CHANGED
@@ -24,6 +24,7 @@ module Textus
24
24
  Check::RuleAmbiguity,
25
25
  Check::HandlerAllowlist,
26
26
  Check::FetchLocks,
27
+ Check::OrphanedPublishTargets,
27
28
  Check::ProposalTargets,
28
29
  ].freeze
29
30
 
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Hooks
5
+ # The single source of truth for hook event names and their required
6
+ # kwargs. EventBus, RpcRegistry, and the Loader DSL router all read these
7
+ # tables directly — the registries do not keep their own copies. Catalog
8
+ # references no other constant, so it has no load-order cycle, which is
9
+ # what removed the previous drift hazard (EventBus held a hard-coded
10
+ # `RPC_EVENTS` list that could fall out of sync with RpcRegistry's table).
11
+ module Catalog
12
+ # Pub-sub events: 0..N handlers, fire-and-forget, receive `ctx:`.
13
+ PUBSUB = {
14
+ entry_put: %i[ctx key envelope],
15
+ entry_deleted: %i[ctx key],
16
+ entry_fetched: %i[ctx key envelope change],
17
+ entry_renamed: %i[ctx key from_key to_key envelope],
18
+ build_completed: %i[ctx key envelope sources],
19
+ proposal_accepted: %i[ctx key target_key],
20
+ proposal_rejected: %i[ctx key target_key],
21
+ file_published: %i[ctx key envelope source target],
22
+ store_loaded: %i[ctx],
23
+ fetch_started: %i[ctx key mode],
24
+ fetch_failed: %i[ctx key error_class error_message],
25
+ fetch_backgrounded: %i[ctx key started_at budget_ms],
26
+ }.freeze
27
+
28
+ # RPC events: single handler, return value matters, receive `caps:`.
29
+ RPC = {
30
+ resolve_intake: %i[caps config args],
31
+ transform_rows: %i[caps rows config],
32
+ validate: %i[caps],
33
+ }.freeze
34
+ end
35
+ end
36
+ end
@@ -7,23 +7,6 @@ module Textus
7
7
 
8
8
  class HookTimeout < StandardError; end
9
9
 
10
- EVENTS = {
11
- entry_put: %i[ctx key envelope],
12
- entry_deleted: %i[ctx key],
13
- entry_fetched: %i[ctx key envelope change],
14
- entry_renamed: %i[ctx key from_key to_key envelope],
15
- build_completed: %i[ctx key envelope sources],
16
- proposal_accepted: %i[ctx key target_key],
17
- proposal_rejected: %i[ctx key target_key],
18
- file_published: %i[ctx key envelope source target],
19
- store_loaded: %i[ctx],
20
- fetch_started: %i[ctx key mode],
21
- fetch_failed: %i[ctx key error_class error_message],
22
- fetch_backgrounded: %i[ctx key started_at budget_ms],
23
- }.freeze
24
-
25
- RPC_EVENTS = %i[resolve_intake transform_rows validate].freeze
26
-
27
10
  def initialize(error_log: ErrorLog.new)
28
11
  @pubsub = Hash.new { |h, k| h[k] = [] }
29
12
  @error_handlers = []
@@ -36,9 +19,9 @@ module Textus
36
19
 
37
20
  def register(event, name, keys: nil, &blk)
38
21
  event_sym = event.to_sym
39
- raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if RPC_EVENTS.include?(event_sym)
22
+ raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if Catalog::RPC.key?(event_sym)
40
23
 
41
- required = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
24
+ required = Catalog::PUBSUB[event_sym] or raise UsageError.new("unknown event: #{event}")
42
25
  sig = Signature.new(blk)
43
26
  missing = sig.missing(required)
44
27
  if missing.any?
@@ -12,7 +12,7 @@ module Textus
12
12
  # Pubsub registration — delegates to EventBus.
13
13
  # Also handles RPC event names by delegating to RpcRegistry.
14
14
  def on(event, name, keys: nil, &)
15
- if Hooks::RpcRegistry::EVENTS.key?(event.to_sym)
15
+ if Hooks::Catalog::RPC.key?(event.to_sym)
16
16
  @rpc.register(event, name, &)
17
17
  else
18
18
  @events.register(event, name, keys: keys, &)
@@ -3,23 +3,15 @@
3
3
  module Textus
4
4
  module Hooks
5
5
  class RpcRegistry
6
- EVENTS = {
7
- resolve_intake: %i[caps config args],
8
- transform_rows: %i[caps rows config],
9
- validate: %i[caps],
10
- }.freeze
11
-
12
- PUBSUB_EVENTS = EventBus::EVENTS.keys.freeze
13
-
14
6
  def initialize
15
7
  @table = Hash.new { |h, k| h[k] = {} }
16
8
  end
17
9
 
18
10
  def register(event, name, &blk)
19
11
  event_sym = event.to_sym
20
- raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if PUBSUB_EVENTS.include?(event_sym)
12
+ raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if Catalog::PUBSUB.key?(event_sym)
21
13
 
22
- required = EVENTS[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
14
+ required = Catalog::RPC[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
23
15
  sig = Signature.new(blk)
24
16
  missing = sig.missing(required)
25
17
  raise UsageError.new("#{event_sym} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})") if missing.any?
@@ -38,7 +30,7 @@ module Textus
38
30
 
39
31
  # Invoke a registered callable, injecting `caps:` only if the callable
40
32
  # declares it (or accepts keyrest). Mis-named kwargs (e.g. the legacy
41
- # `caps:`-alternative) are rejected at registration time, not here.
33
+ # `store:`) are rejected at registration time, not here.
42
34
  def invoke(event, name, caps:, **other)
43
35
  blk = callable(event, name)
44
36
  sig = Signature.new(blk)
@@ -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
@@ -11,9 +11,9 @@ module Textus
11
11
  # declares a `kind: workspace` zone is therefore rejected at load (no
12
12
  # `keep`-holder); declare `roles:` to opt into a workspace lane (ADR 0033).
13
13
  DEFAULT_MAPPING = {
14
- "human" => %w[author propose].freeze,
15
- "agent" => %w[propose].freeze,
16
- "automation" => %w[fetch build].freeze,
14
+ Textus::Role::HUMAN => %w[author propose].freeze,
15
+ Textus::Role::AGENT => %w[propose].freeze,
16
+ Textus::Role::AUTOMATION => %w[fetch build].freeze,
17
17
  }.freeze
18
18
 
19
19
  # Returns { role_name => [verbs] }. When `roles:` is declared we use
@@ -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.
@@ -41,6 +41,7 @@ module Textus
41
41
  return nil if @publish_each.nil?
42
42
 
43
43
  leaves = []
44
+ pruned = [] # accumulates orphans removed by prune_orphans below
44
45
  pctx.manifest.resolver.enumerate(prefix: @key).each do |row|
45
46
  next unless row[:manifest_entry].equal?(self)
46
47
  next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
@@ -53,18 +54,57 @@ module Textus
53
54
  )
54
55
  end
55
56
 
56
- Textus::Ports::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
57
- pctx.emit(:file_published,
58
- key: row[:key],
59
- envelope: pctx.reader.call(row[:key]),
60
- source: row[:path],
61
- target: target_abs)
62
- leaves << { "key" => row[:key], "source" => row[:path], "target" => target_abs }
57
+ written = @index_filename ? publish_subtree(row, target_abs, pctx) : [publish_one(row, target_abs, pctx)]
58
+ pruned.concat(prune_orphans(target_abs, written, pctx)) if @index_filename
59
+ written.each { |w| leaves << { "key" => row[:key], "source" => w["source"], "target" => w["target"] } }
63
60
  end
64
61
 
65
- { kind: :leaves, value: leaves }
62
+ { kind: :leaves, value: leaves, pruned: pruned }
66
63
  end
67
64
 
65
+ def publish_one(row, target_abs, pctx)
66
+ Textus::Ports::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
67
+ pctx.emit(:file_published, key: row[:key], envelope: pctx.reader.call(row[:key]),
68
+ source: row[:path], target: target_abs)
69
+ { "source" => row[:path], "target" => target_abs }
70
+ end
71
+
72
+ def publish_subtree(row, target_dir, pctx)
73
+ base = File.join(pctx.root, "zones", path)
74
+ leaf_dir = File.dirname(row[:path])
75
+ # FNM_DOTMATCH includes dotfiles; File.file? below skips dirs (and symlinks-to-dirs). Leaf trees are authored content, not arbitrary symlink graphs.
76
+ Dir.glob(File.join(leaf_dir, "**", "*"), File::FNM_DOTMATCH).sort.filter_map do |src|
77
+ next nil unless File.file?(src)
78
+
79
+ rel_to_base = src.sub(%r{\A#{Regexp.escape(base)}/}, "")
80
+ next nil if ignored?(rel_to_base)
81
+
82
+ rel_to_leaf = src.sub(%r{\A#{Regexp.escape(leaf_dir)}/}, "")
83
+ dst = File.join(target_dir, rel_to_leaf)
84
+ Textus::Ports::Publisher.publish(source: src, target: dst, store_root: pctx.root)
85
+ pctx.emit(:file_published, key: row[:key], envelope: pctx.reader.call(row[:key]),
86
+ source: src, target: dst)
87
+ { "source" => src, "target" => dst }
88
+ end
89
+ end
90
+
91
+ # Scoped to this leaf's target_dir only. Safe across leaves because ADR 0046
92
+ # Decision 5 (shallowest-index-wins) keeps leaf dirs non-nesting, so {leaf}-derived
93
+ # target dirs never nest and targets_under can't reach another leaf's sentinels.
94
+ def prune_orphans(target_dir, written, pctx)
95
+ kept = written.map { |w| File.expand_path(w["target"]) }
96
+ store = Textus::Ports::SentinelStore.new
97
+ store.targets_under(target_dir, pctx.root).filter_map do |managed|
98
+ next nil if kept.include?(File.expand_path(managed))
99
+
100
+ Textus::Ports::Publisher.unpublish(target: managed, store_root: pctx.root)
101
+ managed
102
+ end
103
+ end
104
+
105
+ # Helpers are private; KIND / self.from_raw / REGISTRY below are intentionally public.
106
+ private :publish_one, :publish_subtree, :prune_orphans
107
+
68
108
  KIND = :nested
69
109
 
70
110
  def self.from_raw(common, raw)
@@ -4,7 +4,7 @@ module Textus
4
4
  module Validators
5
5
  module Events
6
6
  def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
7
- pubsub_events = Textus::Hooks::EventBus::EVENTS.keys
7
+ pubsub_events = Textus::Hooks::Catalog::PUBSUB.keys
8
8
  events = entry.events
9
9
  events.each_key do |evt|
10
10
  next if pubsub_events.include?(evt.to_sym)
@@ -27,13 +27,51 @@ module Textus
27
27
  )
28
28
  end
29
29
 
30
- return if used_vars.any? { |v| REQUIRED_DISCRIMINATOR_VARS.include?(v) }
30
+ validate_discriminator(entry, used_vars, publish_each)
31
+ end
32
+
33
+ def self.validate_discriminator(entry, used_vars, publish_each)
34
+ if entry.index_filename
35
+ forbidden = used_vars & %w[basename ext]
36
+ unless forbidden.empty?
37
+ raise UsageError.new(
38
+ "entry '#{entry.key}': publish_each names a directory " \
39
+ "(index_filename: '#{entry.index_filename}'); {basename}/{ext} are file-only — " \
40
+ "use {leaf} or {key}.",
41
+ )
42
+ end
43
+ last_segment = publish_each.sub(%r{/\z}, "").split("/").last
44
+ if last_segment == entry.index_filename
45
+ raise UsageError.new(
46
+ "entry '#{entry.key}': directory-leaf publish_each must name the target DIRECTORY, " \
47
+ "not the index file — drop the trailing '/#{entry.index_filename}' " \
48
+ "(the whole leaf subtree is copied into the named directory).",
49
+ )
50
+ end
51
+ ext = File.extname(last_segment)
52
+ unless ext.empty?
53
+ raise UsageError.new(
54
+ "entry '#{entry.key}': directory-leaf publish_each names a DIRECTORY target, but its " \
55
+ "final segment '#{last_segment}' looks like a file (extension '#{ext}') — " \
56
+ "drop the extension (the whole leaf subtree is copied into the named directory).",
57
+ )
58
+ end
59
+ return if used_vars.intersect?(%w[leaf key])
60
+
61
+ raise UsageError.new(
62
+ "entry '#{entry.key}': directory-leaf publish_each must reference {leaf} or {key} " \
63
+ "(else every leaf would clobber the same directory).",
64
+ )
65
+ end
66
+
67
+ return if used_vars.intersect?(REQUIRED_DISCRIMINATOR_VARS)
31
68
 
32
69
  raise UsageError.new(
33
70
  "entry '#{entry.key}': publish_each must reference at least one of {leaf}, {basename}, or {key} " \
34
71
  "(else every leaf would clobber the same target).",
35
72
  )
36
73
  end
74
+ private_class_method :validate_discriminator
37
75
  end
38
76
  end
39
77
  end
@@ -34,6 +34,13 @@ module Textus
34
34
  (proposers - roles_with_capability("author")).first || proposers.first
35
35
  end
36
36
 
37
+ # The role textus acts AS for a system-initiated operation requiring
38
+ # `verb` (no human passed --as). Capability-derived — a role name that
39
+ # exists in the manifest, or nil. Never a hardcoded literal (ADR 0044).
40
+ def actor_for(verb)
41
+ roles_with_capability(verb).first
42
+ end
43
+
37
44
  # The roles authorized to write `zone_name`: those holding the verb its
38
45
  # kind requires. Raises on an undeclared zone.
39
46
  def zone_writers(zone_name)
@@ -72,8 +72,21 @@ module Textus
72
72
  return [] unless File.directory?(base)
73
73
 
74
74
  entry_index_filename = entry.index_filename
75
- glob_pattern = entry_index_filename ? "**/#{entry_index_filename}" : nested_glob(entry.format)
76
- Dir.glob(File.join(base, glob_pattern)).filter_map { |path| nested_row_for(entry, base, path) }
75
+ unless entry_index_filename
76
+ return Dir.glob(File.join(base, nested_glob(entry.format)))
77
+ .filter_map { |path| nested_row_for(entry, base, path) }
78
+ end
79
+
80
+ claimed = []
81
+ Dir.glob(File.join(base, "**", entry_index_filename))
82
+ .sort_by { |path| path.count("/") }
83
+ .filter_map do |path|
84
+ leaf_dir = File.dirname(path)
85
+ next nil if claimed.any? { |d| leaf_dir.start_with?("#{d}/") }
86
+
87
+ claimed << leaf_dir
88
+ nested_row_for(entry, base, path)
89
+ end
77
90
  end
78
91
 
79
92
  def nested_row_for(entry, base, path)
@@ -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 ignore
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
@@ -35,6 +35,13 @@ module Textus
35
35
  RETENTION_KEYS = %w[expire_after archive_after].freeze
36
36
  AUDIT_KEYS = %w[max_size keep].freeze
37
37
 
38
+ # Syntactic shape of an `owner:` subject token (the `patrick` in
39
+ # `human:patrick`) — the subject half of the owner-validation rule below.
40
+ # Role supplies the archetype set (Role::NAMES); this pattern is the
41
+ # owner-specific part, so it lives with the rule that composes them
42
+ # (ADR 0045 D1). Acting-role *names* are gated by Role::NAMES, not a regex.
43
+ OWNER_SUBJECT_PATTERN = /\A[a-z][a-z0-9_-]*\z/
44
+
38
45
  def self.validate!(raw)
39
46
  raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
40
47
 
@@ -42,6 +49,7 @@ module Textus
42
49
  validate_roles!(raw["roles"])
43
50
  validate_zones!(raw["zones"])
44
51
  validate_entries!(raw["entries"])
52
+ validate_owners!(raw["zones"], raw["entries"])
45
53
  validate_rules!(raw["rules"])
46
54
  walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
47
55
  validate_single_queue!(raw)
@@ -91,6 +99,12 @@ module Textus
91
99
  path = "$.roles[#{i}]"
92
100
  walk(r, ROLE_KEYS, path)
93
101
  name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
102
+ unless Textus::Role::NAMES.include?(name)
103
+ raise BadManifest.new(
104
+ "unknown role name '#{name}' at '#{path}' " \
105
+ "(allowed: #{Textus::Role::NAMES.join(", ")})",
106
+ )
107
+ end
94
108
  Array(r["can"]).each do |verb|
95
109
  next if CAPABILITIES.include?(verb)
96
110
 
@@ -109,6 +123,47 @@ module Textus
109
123
  )
110
124
  end
111
125
 
126
+ # Owners are validated against the SAME closed archetype set as role names
127
+ # (ADR 0045 D1) so attribution can't bypass the closed-name guarantee.
128
+ # Applies to both zone owners and entry owners; owner is optional, so a
129
+ # nil owner is not an error.
130
+ def self.validate_owners!(zones, entries)
131
+ Array(zones).each_with_index do |z, i|
132
+ check_owner!(z["owner"], "$.zones[#{i}]")
133
+ end
134
+ Array(entries).each_with_index do |e, i|
135
+ check_owner!(e["owner"], "$.entries[#{i}]")
136
+ end
137
+ end
138
+
139
+ def self.check_owner!(owner, path)
140
+ return if owner.nil?
141
+ return if valid_owner?(owner)
142
+
143
+ raise BadManifest.new(
144
+ "invalid owner '#{owner}' at '#{path}' " \
145
+ "(expected <archetype> or <archetype>:<subject>, " \
146
+ "archetype one of: #{Textus::Role::NAMES.join(", ")})",
147
+ )
148
+ end
149
+
150
+ # The owner-validation rule: an `owner:` token is either a bare archetype
151
+ # (`agent`) or `<archetype>:<subject>` (`human:patrick`). The archetype is
152
+ # gated against the closed Role::NAMES set (so attribution can't smuggle in
153
+ # a name the role side rejects, ADR 0045 D1); the subject is the free-form
154
+ # principal, validated by OWNER_SUBJECT_PATTERN. Split on the FIRST ':'
155
+ # only — a subject may not itself contain ':' (the pattern excludes it), so
156
+ # `human:a:b` is rejected.
157
+ def self.valid_owner?(token)
158
+ return false unless token.is_a?(String) && !token.empty?
159
+
160
+ archetype, subject = token.split(":", 2)
161
+ return false unless Textus::Role::NAMES.include?(archetype)
162
+ return true if subject.nil?
163
+
164
+ OWNER_SUBJECT_PATTERN.match?(subject)
165
+ end
166
+
112
167
  def self.validate_fetch_timeout!(value, path)
113
168
  return if value.nil?
114
169
  return if value.is_a?(Integer) && value.positive? && value <= FETCH_TIMEOUT_SECONDS_CEILING
@@ -33,7 +33,7 @@ module Textus
33
33
  extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
34
34
  extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
35
35
  @audit_log.append(
36
- role: "automation", verb: "event_error", key: key,
36
+ role: Textus::Role::AUTOMATION, verb: "event_error", key: key,
37
37
  etag_before: nil, etag_after: nil, extras: extras
38
38
  )
39
39
  end
@@ -8,6 +8,10 @@ module Textus
8
8
  Process.respond_to?(:fork)
9
9
  end
10
10
 
11
+ def acting_role(store)
12
+ store.manifest.policy.actor_for("fetch")
13
+ end
14
+
11
15
  def spawn(store_root:, key:)
12
16
  return nil unless supported?
13
17
 
@@ -21,7 +25,13 @@ module Textus
21
25
 
22
26
  begin
23
27
  store = Textus::Store.new(store_root)
24
- store.as("automation").fetch(key)
28
+ # No fetch-holder configured — exit the child cleanly. In practice
29
+ # this is unreachable: the background fork only happens after a
30
+ # foreground fetch was already authorized (so a fetch-holder
31
+ # exists). Config-time detection is doctor's job (ADR 0044 Q2).
32
+ role = acting_role(store)
33
+ exit(0) unless role
34
+ store.as(role).fetch(key)
25
35
  rescue StandardError
26
36
  # Already logged via :fetch_failed; exit cleanly.
27
37
  ensure
@@ -18,6 +18,16 @@ module Textus
18
18
  Textus::Ports::SentinelStore.new.write!(target: target, source: source, store_root: store_root)
19
19
  end
20
20
 
21
+ # Removes a previously-published file and its sentinel. No-op unless the
22
+ # target is textus-managed — never deletes an unmanaged file.
23
+ def self.unpublish(target:, store_root:)
24
+ return unless managed?(target, store_root)
25
+
26
+ FileUtils.rm_f(target)
27
+ sentinel = Textus::Ports::SentinelStore.new.sentinel_path(target, store_root)
28
+ FileUtils.rm_f(sentinel)
29
+ end
30
+
21
31
  def self.refuse_if_unmanaged(target, store_root)
22
32
  return unless File.exist?(target) || File.symlink?(target)
23
33
  return if managed?(target, store_root)
@@ -42,6 +42,21 @@ module Textus
42
42
  File.join(store_root, DIR, rel + SUFFIX)
43
43
  end
44
44
 
45
+ # Absolute target paths of every sentinel recorded under `target_dir`.
46
+ def targets_under(target_dir, store_root)
47
+ repo_root = File.dirname(store_root)
48
+ rel = relative_to(target_dir, repo_root) or return []
49
+ sdir = File.join(store_root, DIR, rel)
50
+ return [] unless File.directory?(sdir)
51
+
52
+ prefix = File.join(store_root, DIR) + "/"
53
+ Dir.glob(File.join(sdir, "**", "*#{SUFFIX}")).map do |spath|
54
+ # strip the sentinel-store prefix and the .textus-managed.json suffix to recover the repo-relative target path
55
+ trel = spath.delete_prefix(prefix).delete_suffix(SUFFIX)
56
+ File.join(repo_root, trel)
57
+ end
58
+ end
59
+
45
60
  private
46
61
 
47
62
  def rel_or_abs(path, repo_root)
data/lib/textus/role.rb CHANGED
@@ -1,15 +1,26 @@
1
1
  module Textus
2
2
  module Role
3
- PATTERN = /\A[a-z][a-z0-9_-]*\z/
4
- DEFAULT = "human".freeze
5
- # The default acting identity for the MCP transport (ADR 0040): an agent
6
- # over stdio proposes; it does not inherit the human's authority. CLI
7
- # callers keep the `human` DEFAULT.
8
- AGENT = "agent".freeze
3
+ # The three role archetypes, each string sourced exactly once: human curates
4
+ # canon, agent proposes, automation fetches/builds (explanation/concepts.md).
5
+ # Reference these constants instead of bare literals (ADR 0044).
6
+ HUMAN = "human".freeze
7
+ AGENT = "agent".freeze
8
+ AUTOMATION = "automation".freeze
9
+
10
+ # The closed set of legal role names (ADR 0045), built FROM the archetypes
11
+ # above so it stays the single source of truth — a manifest declaring any
12
+ # other name is rejected at load, and DEFAULT ∈ NAMES holds structurally.
13
+ # Capabilities (`can:`) remain freely tunable per role.
14
+ NAMES = [HUMAN, AGENT, AUTOMATION].freeze
15
+
16
+ # Default acting identity (ADR 0040): a *choice* over the vocabulary, not a
17
+ # new name. CLI callers act as the human; an agent over stdio proposes and
18
+ # does not inherit the human's authority (it defaults to AGENT per transport).
19
+ DEFAULT = HUMAN
9
20
 
10
21
  def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
11
22
  candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
12
- raise InvalidRole.new(candidate) unless candidate.match?(PATTERN)
23
+ raise InvalidRole.new(candidate) unless NAMES.include?(candidate)
13
24
 
14
25
  candidate
15
26
  end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.39.0"
2
+ VERSION = "0.40.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -18,6 +18,7 @@ module Textus
18
18
  def call(prefix: nil)
19
19
  built = []
20
20
  leaves = []
21
+ pruned = []
21
22
  context = build_context
22
23
 
23
24
  @manifest.data.entries.each do |mentry|
@@ -27,12 +28,14 @@ module Textus
27
28
  next if result.nil?
28
29
 
29
30
  case result[:kind]
30
- when :built then built << result[:value]
31
- when :leaves then leaves.concat(result[:value])
31
+ when :built then built << result[:value]
32
+ when :leaves
33
+ leaves.concat(result[:value])
34
+ pruned.concat(result[:pruned]) if result[:pruned]
32
35
  end
33
36
  end
34
37
 
35
- { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => leaves }
38
+ { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => leaves, "pruned" => pruned }
36
39
  end
37
40
 
38
41
  private
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.39.0
4
+ version: 0.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -103,7 +103,6 @@ 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
@@ -177,6 +176,7 @@ files:
177
176
  - lib/textus/doctor/check/illegal_keys.rb
178
177
  - lib/textus/doctor/check/intake_registration.rb
179
178
  - lib/textus/doctor/check/manifest_files.rb
179
+ - lib/textus/doctor/check/orphaned_publish_targets.rb
180
180
  - lib/textus/doctor/check/proposal_targets.rb
181
181
  - lib/textus/doctor/check/protocol_version.rb
182
182
  - lib/textus/doctor/check/rule_ambiguity.rb
@@ -226,6 +226,7 @@ files:
226
226
  - lib/textus/errors.rb
227
227
  - lib/textus/etag.rb
228
228
  - lib/textus/hooks/builtin.rb
229
+ - lib/textus/hooks/catalog.rb
229
230
  - lib/textus/hooks/context.rb
230
231
  - lib/textus/hooks/error_log.rb
231
232
  - lib/textus/hooks/event_bus.rb
@@ -234,6 +235,7 @@ files:
234
235
  - lib/textus/hooks/rpc_registry.rb
235
236
  - lib/textus/hooks/signature.rb
236
237
  - lib/textus/init.rb
238
+ - lib/textus/init/templates/machine_intake.rb
237
239
  - lib/textus/key/distance.rb
238
240
  - lib/textus/key/grammar.rb
239
241
  - lib/textus/key/path.rb
data/ARCHITECTURE.md DELETED
@@ -1,3 +0,0 @@
1
- # Architecture
2
-
3
- Moved to [`docs/architecture/README.md`](docs/architecture/README.md).