textus 0.39.1 → 0.41.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -1
  3. data/SPEC.md +31 -7
  4. data/docs/architecture/README.md +256 -0
  5. data/docs/reference/conventions.md +148 -0
  6. data/lib/textus/boot.rb +2 -2
  7. data/lib/textus/cli/verb/build.rb +5 -1
  8. data/lib/textus/cli/verb/hook_run.rb +3 -1
  9. data/lib/textus/cli/verb/hooks.rb +3 -3
  10. data/lib/textus/cli/verb/put.rb +1 -1
  11. data/lib/textus/doctor/check/orphaned_publish_targets.rb +35 -0
  12. data/lib/textus/doctor/check/publish_tree_index_overlap.rb +48 -0
  13. data/lib/textus/doctor.rb +2 -0
  14. data/lib/textus/hooks/catalog.rb +36 -0
  15. data/lib/textus/hooks/event_bus.rb +2 -19
  16. data/lib/textus/hooks/loader.rb +1 -1
  17. data/lib/textus/hooks/rpc_registry.rb +3 -11
  18. data/lib/textus/manifest/capabilities.rb +3 -3
  19. data/lib/textus/manifest/entry/base.rb +12 -20
  20. data/lib/textus/manifest/entry/nested.rb +8 -49
  21. data/lib/textus/manifest/entry/publish/each.rb +83 -0
  22. data/lib/textus/manifest/entry/publish/each_dir.rb +74 -0
  23. data/lib/textus/manifest/entry/publish/each_file.rb +29 -0
  24. data/lib/textus/manifest/entry/publish/mode.rb +39 -0
  25. data/lib/textus/manifest/entry/publish/none.rb +14 -0
  26. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +72 -0
  27. data/lib/textus/manifest/entry/publish/template.rb +22 -0
  28. data/lib/textus/manifest/entry/publish/to_paths.rb +27 -0
  29. data/lib/textus/manifest/entry/publish/tree.rb +54 -0
  30. data/lib/textus/manifest/entry/publish.rb +45 -0
  31. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  32. data/lib/textus/manifest/entry/validators/publish.rb +26 -0
  33. data/lib/textus/manifest/entry/validators.rb +1 -1
  34. data/lib/textus/manifest/policy.rb +7 -0
  35. data/lib/textus/manifest/resolver.rb +15 -2
  36. data/lib/textus/manifest/schema.rb +56 -1
  37. data/lib/textus/ports/audit_subscriber.rb +1 -1
  38. data/lib/textus/ports/fetch/detached.rb +11 -1
  39. data/lib/textus/ports/publisher.rb +24 -2
  40. data/lib/textus/ports/sentinel_store.rb +15 -0
  41. data/lib/textus/role.rb +18 -7
  42. data/lib/textus/version.rb +1 -1
  43. data/lib/textus/write/fetch_events.rb +42 -0
  44. data/lib/textus/write/fetch_orchestrator.rb +2 -3
  45. data/lib/textus/write/fetch_worker.rb +13 -22
  46. data/lib/textus/write/intake_fetch.rb +8 -6
  47. data/lib/textus/write/publish.rb +6 -3
  48. metadata +18 -2
  49. data/lib/textus/manifest/entry/validators/publish_each.rb +0 -41
@@ -0,0 +1,72 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Publish
5
+ # ADR 0049: the one walk->publish->prune pipeline shared by EachDir
6
+ # (per-leaf subtree, ADR 0046) and Tree (whole-entry mirror, ADR 0047).
7
+ # The two used to be near-duplicate methods (publish_subtree +
8
+ # publish_tree_via, prune_orphans + prune_tree); their only real
9
+ # difference — whether the prune honors the entry's `ignore` — is now the
10
+ # explicit `prune_honors_ignore:` parameter.
11
+ class SubtreeMirror
12
+ def initialize(entry, pctx)
13
+ @entry = entry
14
+ @pctx = pctx
15
+ end
16
+
17
+ # base: store dir the entry owns — the root `ignored?` globs are
18
+ # relative to (ADR 0042).
19
+ # walk_root: dir the glob is rooted at (a single leaf dir for EachDir,
20
+ # == base for Tree). dst paths mirror rel-to-walk_root.
21
+ # target_dir: repo-side destination root.
22
+ # key/envelope: emitted per file; envelope is nil for the keyless Tree.
23
+ # prune_honors_ignore: when true a managed file the entry `ignore`s
24
+ # survives the prune (ADR 0047 D4 — lets a derived index live in the
25
+ # mirrored dir); when false every unwritten managed file is pruned.
26
+ def mirror(base:, walk_root:, target_dir:, key:, envelope:, prune_honors_ignore:)
27
+ return { written: [], pruned: [] } unless File.directory?(walk_root)
28
+
29
+ written = publish_files(base: base, walk_root: walk_root, target_dir: target_dir, key: key, envelope: envelope)
30
+ { written: written, pruned: prune(target_dir, written, prune_honors_ignore) }
31
+ end
32
+
33
+ private
34
+
35
+ def publish_files(base:, walk_root:, target_dir:, key:, envelope:)
36
+ # FNM_DOTMATCH includes dotfiles; File.file? below skips dirs (and
37
+ # symlinks-to-dirs). Trees are authored content, not symlink graphs.
38
+ Dir.glob(File.join(walk_root, "**", "*"), File::FNM_DOTMATCH).sort.filter_map do |src|
39
+ next nil unless File.file?(src)
40
+ next nil if @entry.ignored?(relative(src, base))
41
+
42
+ dst = File.join(target_dir, relative(src, walk_root))
43
+ Textus::Ports::Publisher.publish(source: src, target: dst, store_root: @pctx.root)
44
+ @pctx.emit(:file_published, key: key, envelope: envelope, source: src, target: dst)
45
+ { "key" => key, "source" => src, "target" => dst }
46
+ end
47
+ end
48
+
49
+ # Scoped to target_dir. Safe across leaves because ADR 0046 D5
50
+ # (shallowest-index-wins) keeps leaf target dirs non-nesting, so
51
+ # targets_under can't reach another leaf's sentinels.
52
+ def prune(target_dir, written, honor_ignore)
53
+ kept = written.map { |w| File.expand_path(w["target"]) }
54
+ store = Textus::Ports::SentinelStore.new
55
+ store.targets_under(target_dir, @pctx.root).filter_map do |managed|
56
+ abs = File.expand_path(managed)
57
+ next nil if kept.include?(abs)
58
+ next nil if honor_ignore && @entry.ignored?(relative(abs, target_dir))
59
+
60
+ Textus::Ports::Publisher.unpublish(target: managed, store_root: @pctx.root)
61
+ managed
62
+ end
63
+ end
64
+
65
+ def relative(path, root)
66
+ path.sub(%r{\A#{Regexp.escape(root)}/}, "")
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,22 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Publish
5
+ # The publish_each template vocabulary. A publish_each value is a path
6
+ # with `{leaf}`, `{basename}`, `{key}`, `{ext}` placeholders; a
7
+ # publish_tree value must be a plain path (any var is an error), so the
8
+ # modes reuse VAR_RE to detect stray vars there too.
9
+ module Template
10
+ KNOWN_VARS = %w[leaf basename key ext].freeze
11
+ VAR_RE = /\{([a-z]+)\}/
12
+ REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
13
+
14
+ # Substitute `{var}` placeholders from a string-keyed hash.
15
+ def self.expand(template, vars)
16
+ template.gsub(VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,27 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Publish
5
+ # publish_to: copy the entry's one stored file to each fixed repo path.
6
+ # The default behaviour of any entry that declares `publish_to:`.
7
+ class ToPaths < Mode
8
+ def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
9
+ targets = Array(entry.publish_to)
10
+ return nil if targets.empty?
11
+
12
+ source_path = pctx.manifest.resolver.resolve(entry.key).path
13
+ envelope = pctx.reader.call(entry.key)
14
+
15
+ targets.each do |rel|
16
+ target_abs = File.join(pctx.repo_root, rel)
17
+ Textus::Ports::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
18
+ pctx.emit(:file_published, key: entry.key, envelope: envelope, source: source_path, target: target_abs)
19
+ end
20
+
21
+ { kind: :built, value: { "key" => entry.key, "path" => source_path, "published_to" => targets } }
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,54 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Publish
5
+ # publish_tree (ADR 0047): mirror this entry's whole subtree to one
6
+ # target dir by real path. No resolver, no keys — files are opaque
7
+ # payload (envelope nil). The prune honors `ignore` so a derived index
8
+ # (e.g. a SKILL.md written by a separate entry into the same dir)
9
+ # survives the whole-target prune (ADR 0047 D4).
10
+ class Tree < Mode
11
+ def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
12
+ target_rel = entry.publish_tree
13
+ target_dir = repo_abs(pctx, target_rel)
14
+ unless inside_repo?(pctx, target_dir)
15
+ raise Textus::PublishError.new(
16
+ "entry '#{entry.key}': publish_tree target '#{target_rel}' escapes repo root",
17
+ )
18
+ end
19
+
20
+ result = SubtreeMirror.new(entry, pctx).mirror(
21
+ base: store_base(pctx),
22
+ walk_root: store_base(pctx),
23
+ target_dir: target_dir,
24
+ key: entry.key,
25
+ envelope: nil,
26
+ prune_honors_ignore: true,
27
+ )
28
+ { kind: :leaves, value: result[:written], pruned: result[:pruned] }
29
+ end
30
+
31
+ def validate!
32
+ publish_tree = entry.publish_tree
33
+ raise UsageError.new("entry '#{entry.key}': publish_tree must be a string") unless publish_tree.is_a?(String)
34
+
35
+ unless entry.index_filename.nil?
36
+ raise UsageError.new(
37
+ "entry '#{entry.key}': index_filename and publish_tree are mutually exclusive — " \
38
+ "publish_tree mirrors a whole subtree by path and never enumerates an index.",
39
+ )
40
+ end
41
+
42
+ used_vars = publish_tree.scan(Template::VAR_RE).flatten
43
+ return if used_vars.empty?
44
+
45
+ raise UsageError.new(
46
+ "entry '#{entry.key}': publish_tree names a single directory and takes no template variable(s) " \
47
+ "#{used_vars.map { |v| "{#{v}}" }.join(", ")} — it mirrors the whole subtree to one target dir.",
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,45 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ # ADR 0049: the publish design is a three-key concept (ADR 0047 table)
5
+ # realized as one resolved sum type. Each directory entry resolves, once,
6
+ # to one Publish::* mode that owns its publish algorithm — no nil-cascade,
7
+ # no pairwise exclusivity guards, one shared subtree mirror.
8
+ #
9
+ # None — nothing to publish
10
+ # ToPaths — publish_to: 1 stored file -> N fixed repo paths
11
+ # EachFile — publish_each (file leaves): 1 leaf file -> 1 templated path
12
+ # EachDir — publish_each + index_filename: 1 leaf subtree -> 1 templated dir
13
+ # Tree — publish_tree: whole entry subtree -> 1 dir, no keys
14
+ module Publish
15
+ # Resolve an entry to its single publish mode. Raises one UsageError if
16
+ # more than one of {publish_to, publish_each, publish_tree} is set —
17
+ # exclusivity is structural here, not four scattered pairwise guards.
18
+ def self.resolve(entry)
19
+ set = []
20
+ set << "publish_to" unless Array(entry.publish_to).empty?
21
+ set << "publish_each" unless entry.publish_each.nil?
22
+ set << "publish_tree" unless entry.publish_tree.nil?
23
+
24
+ if set.length > 1
25
+ raise Textus::UsageError.new(
26
+ "entry '#{entry.key}': #{set.join(", ")} are mutually exclusive — an entry publishes exactly one way",
27
+ )
28
+ end
29
+
30
+ mode_for(entry, set.first)
31
+ end
32
+
33
+ def self.mode_for(entry, key)
34
+ case key
35
+ when "publish_to" then ToPaths.new(entry)
36
+ when "publish_tree" then Tree.new(entry)
37
+ when "publish_each" then entry.index_filename ? EachDir.new(entry) : EachFile.new(entry)
38
+ else None.new(entry)
39
+ end
40
+ end
41
+ private_class_method :mode_for
42
+ end
43
+ end
44
+ end
45
+ end
@@ -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)
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ # ADR 0049: one publish validator. Exclusivity among the publish keys is
6
+ # enforced structurally by Publish.resolve (reached via #publish_mode),
7
+ # and each mode's shape rules run *because that mode resolved* — replacing
8
+ # the four scattered pairwise "not-both" guards of the old PublishEach +
9
+ # PublishTree validators. Misuse on a non-nested entry is still caught
10
+ # here from raw, since the typed attrs stub nil on Base.
11
+ module Publish
12
+ def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
13
+ unless entry.nested?
14
+ %w[publish_each publish_tree].each do |key|
15
+ raise UsageError.new("entry '#{entry.key}': #{key} requires nested: true") if entry.raw[key]
16
+ end
17
+ return
18
+ end
19
+
20
+ entry.publish_mode.validate!
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -4,7 +4,7 @@ module Textus
4
4
  module Validators
5
5
  REGISTERED = [
6
6
  Events,
7
- PublishEach,
7
+ Publish,
8
8
  InjectBoot,
9
9
  IndexFilename,
10
10
  Ignore,
@@ -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)
@@ -24,7 +24,7 @@ module Textus
24
24
  KIND_REQUIRES_VERB = LANES
25
25
  ENTRY_KEYS = %w[
26
26
  key path zone kind schema owner nested format
27
- compute template publish_to publish_each
27
+ compute template publish_to publish_each publish_tree
28
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
@@ -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
@@ -12,19 +12,41 @@ module Textus
12
12
  module Publisher
13
13
  def self.publish(source:, target:, store_root:)
14
14
  FileUtils.mkdir_p(File.dirname(target))
15
- refuse_if_unmanaged(target, store_root)
15
+ guard_clobber(source, target, store_root)
16
16
  File.delete(target) if File.symlink?(target)
17
17
  FileUtils.cp(source, target)
18
18
  Textus::Ports::SentinelStore.new.write!(target: target, source: source, store_root: store_root)
19
19
  end
20
20
 
21
- def self.refuse_if_unmanaged(target, store_root)
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
+
31
+ # Refuse to clobber an unmanaged target — EXCEPT adopt one whose bytes
32
+ # already equal the source (ADR 0050: a migration copies files into the
33
+ # store and publishes them back to where they already live, so the target
34
+ # is byte-identical — nothing is at risk). Adoption writes no sentinel
35
+ # here; the normal publish path below does, and the cp is a content no-op.
36
+ # An unmanaged target whose content DIFFERS, or any unmanaged symlink, is
37
+ # still refused — that is the guard's real job.
38
+ def self.guard_clobber(source, target, store_root)
22
39
  return unless File.exist?(target) || File.symlink?(target)
23
40
  return if managed?(target, store_root)
41
+ return if adoptable?(source, target)
24
42
 
25
43
  raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
26
44
  end
27
45
 
46
+ def self.adoptable?(source, target)
47
+ !File.symlink?(target) && File.file?(target) && FileUtils.identical?(source, target)
48
+ end
49
+
28
50
  def self.managed?(target, store_root)
29
51
  File.exist?(Textus::Ports::SentinelStore.new.sentinel_path(target, store_root))
30
52
  end
@@ -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.41.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -0,0 +1,42 @@
1
+ module Textus
2
+ module Write
3
+ # Single home for the fetch lifecycle event vocabulary (ADR 0048 D5). Both
4
+ # FetchWorker (synchronous semantics) and FetchOrchestrator (async policy)
5
+ # emit through this seam so the event names and payload shapes live in one
6
+ # place with one derived hook context.
7
+ class FetchEvents
8
+ def self.from(container:, call:)
9
+ new(
10
+ events: container.events,
11
+ hook_context: Textus::Hooks::Context.for(container: container, call: call),
12
+ )
13
+ end
14
+
15
+ def initialize(events:, hook_context:)
16
+ @events = events
17
+ @hook_context = hook_context
18
+ end
19
+
20
+ def started(key, mode: :sync)
21
+ @events.publish(:fetch_started, ctx: @hook_context, key: key, mode: mode)
22
+ end
23
+
24
+ def failed(key, error)
25
+ @events.publish(:fetch_failed, ctx: @hook_context, key: key,
26
+ error_class: error.class.name, error_message: error.message)
27
+ end
28
+
29
+ def fetched(key, envelope, change)
30
+ return if change == :unchanged
31
+
32
+ @events.publish(:entry_fetched, ctx: @hook_context, key: key, envelope: envelope, change: change)
33
+ end
34
+
35
+ def backgrounded(key, started_at:, budget_ms:)
36
+ payload = { key: key, started_at: started_at, budget_ms: budget_ms }
37
+ payload[:ctx] = @hook_context if @hook_context
38
+ @events.publish(:fetch_backgrounded, **payload)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -10,6 +10,7 @@ module Textus
10
10
  @events = events
11
11
  @hook_context = hook_context
12
12
  @detached_spawner = detached_spawner || default_spawner
13
+ @fetch_events = Textus::Write::FetchEvents.new(events: @events, hook_context: @hook_context)
13
14
  end
14
15
 
15
16
  def execute(action, key:)
@@ -82,9 +83,7 @@ module Textus
82
83
 
83
84
  probe.release
84
85
 
85
- payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
86
- payload[:ctx] = @hook_context if @hook_context
87
- @events.publish(:fetch_backgrounded, **payload)
86
+ @fetch_events.backgrounded(key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms)
88
87
  @detached_spawner.call(store_root: @store_root, key: key)
89
88
  Textus::Domain::Outcome::Detached.new
90
89
  elsif result.is_a?(Textus::Error)