textus 0.39.1 → 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: af15d8a77f0d71c3fab21dc246545b3a7b84242361c518b1d901c491c63e55c4
4
- data.tar.gz: 4644479ed6df331a6973806fed302b29446d3806d1793d8ea9d9fa661000e986
3
+ metadata.gz: 96c684d8a670835fb687a321abb85838ec831e95e8672b7f03453266e2224b65
4
+ data.tar.gz: d9332836814f977824413a7bc379666cfd830f182d2c44a9ed5a71d1e7336513
5
5
  SHA512:
6
- metadata.gz: ee1cf4024e3583c1e59791b279a2fd0f8c00e426f05391267db0239d1a7d2909c521b94bc27caf62d549d902b0dc7f62847178f0ea17973e14b26689a981302a
7
- data.tar.gz: 775daa6d3992b0b93d2a57c230fbfc5705b50fec592327356f4fa6ccc4dc39aaacdcbd1c24d2dd878a1b6612fd5e9c3416ede0b42d9ba90978dc8f8461c6d014
6
+ metadata.gz: cbfdd6527a26f2e8fb97cdde2cc99c5df4d36839af1f426bf52156335187d4b48ecbe45af1b271031861bc225d100676522082da6f74c171555bd5cf7bd0b227
7
+ data.tar.gz: 38ac5fff2e9209c8bbbc15745a1ddbd840736ddcc23363c6a61099926b79807a9ccb49630a98b69f6ed41cf2a15d3272bd586f6bc749ea381e4d24158eeb597a
data/CHANGELOG.md CHANGED
@@ -11,6 +11,29 @@ 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
+
14
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))
15
38
 
16
39
  No `textus/3` wire-format change. `textus init` scaffolds an additional
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)
@@ -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
@@ -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)
@@ -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.1"
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
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.1
4
+ version: 0.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -176,6 +176,7 @@ files:
176
176
  - lib/textus/doctor/check/illegal_keys.rb
177
177
  - lib/textus/doctor/check/intake_registration.rb
178
178
  - lib/textus/doctor/check/manifest_files.rb
179
+ - lib/textus/doctor/check/orphaned_publish_targets.rb
179
180
  - lib/textus/doctor/check/proposal_targets.rb
180
181
  - lib/textus/doctor/check/protocol_version.rb
181
182
  - lib/textus/doctor/check/rule_ambiguity.rb
@@ -225,6 +226,7 @@ files:
225
226
  - lib/textus/errors.rb
226
227
  - lib/textus/etag.rb
227
228
  - lib/textus/hooks/builtin.rb
229
+ - lib/textus/hooks/catalog.rb
228
230
  - lib/textus/hooks/context.rb
229
231
  - lib/textus/hooks/error_log.rb
230
232
  - lib/textus/hooks/event_bus.rb