textus 0.10.4 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -3
  3. data/README.md +45 -86
  4. data/SPEC.md +266 -138
  5. data/docs/conventions.md +47 -15
  6. data/lib/textus/application/reads/freshness.rb +2 -2
  7. data/lib/textus/application/reads/get.rb +1 -1
  8. data/lib/textus/application/reads/policy_explain.rb +2 -2
  9. data/lib/textus/application/refresh/orchestrator.rb +1 -1
  10. data/lib/textus/application/refresh/worker.rb +5 -5
  11. data/lib/textus/application/writes/accept.rb +19 -1
  12. data/lib/textus/application/writes/build.rb +5 -5
  13. data/lib/textus/application/writes/delete.rb +2 -3
  14. data/lib/textus/application/writes/publish.rb +1 -1
  15. data/lib/textus/application/writes/put.rb +3 -6
  16. data/lib/textus/builder/pipeline.rb +1 -1
  17. data/lib/textus/builder/renderer/json.rb +1 -1
  18. data/lib/textus/builder/renderer/yaml.rb +1 -1
  19. data/lib/textus/cli/group/key.rb +1 -1
  20. data/lib/textus/cli/group/refresh.rb +21 -0
  21. data/lib/textus/cli/group/rule.rb +11 -0
  22. data/lib/textus/cli/verb/build.rb +1 -1
  23. data/lib/textus/cli/verb/hook_run.rb +3 -2
  24. data/lib/textus/cli/verb/hooks.rb +1 -1
  25. data/lib/textus/cli/verb/{migrate_keys.rb → key_normalize.rb} +1 -1
  26. data/lib/textus/cli/verb/put.rb +1 -1
  27. data/lib/textus/cli/verb/{policy_explain.rb → rule_explain.rb} +1 -1
  28. data/lib/textus/cli/verb/{policy_list.rb → rule_list.rb} +3 -3
  29. data/lib/textus/cli/verb.rb +3 -2
  30. data/lib/textus/cli.rb +6 -6
  31. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  32. data/lib/textus/doctor/check/illegal_keys.rb +39 -16
  33. data/lib/textus/doctor/check/intake_registration.rb +4 -4
  34. data/lib/textus/doctor/check/protocol_version.rb +47 -0
  35. data/lib/textus/doctor/check/{policy_ambiguity.rb → rule_ambiguity.rb} +6 -6
  36. data/lib/textus/doctor.rb +5 -4
  37. data/lib/textus/domain/permission.rb +4 -4
  38. data/lib/textus/domain/policy/predicates/human_accept.rb +31 -0
  39. data/lib/textus/domain/policy/predicates/schema_valid.rb +50 -0
  40. data/lib/textus/domain/policy/promotion.rb +45 -0
  41. data/lib/textus/errors.rb +24 -5
  42. data/lib/textus/hooks/builtin.rb +5 -5
  43. data/lib/textus/hooks/dispatcher.rb +1 -1
  44. data/lib/textus/hooks/dsl.rb +3 -10
  45. data/lib/textus/hooks/loader.rb +1 -2
  46. data/lib/textus/hooks/registry.rb +22 -21
  47. data/lib/textus/infra/refresh/detached.rb +1 -1
  48. data/lib/textus/init.rb +25 -34
  49. data/lib/textus/intro.rb +9 -9
  50. data/lib/textus/manifest/entry.rb +66 -6
  51. data/lib/textus/manifest/{policies.rb → rules.rb} +12 -10
  52. data/lib/textus/manifest/schema.rb +49 -0
  53. data/lib/textus/manifest.rb +79 -39
  54. data/lib/textus/migrate_keys.rb +1 -1
  55. data/lib/textus/projection.rb +4 -4
  56. data/lib/textus/refresh.rb +1 -1
  57. data/lib/textus/store/mover.rb +91 -50
  58. data/lib/textus/store/staleness/generator_check.rb +88 -0
  59. data/lib/textus/store/staleness/intake_check.rb +46 -0
  60. data/lib/textus/store/staleness.rb +9 -104
  61. data/lib/textus/store/writer.rb +14 -12
  62. data/lib/textus/store.rb +1 -1
  63. data/lib/textus/version.rb +2 -2
  64. data/lib/textus.rb +1 -0
  65. metadata +15 -7
  66. data/lib/textus/cli/group/policy.rb +0 -11
@@ -0,0 +1,47 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Doctor
5
+ class Check
6
+ # Runs as a standalone module (Check::ProtocolVersion.run(root:)) and also
7
+ # as a class-based doctor check (ProtocolVersion.new(store).call).
8
+ class ProtocolVersion < Check
9
+ # Standalone interface: root is the project root (parent of .textus/).
10
+ def self.run(root:)
11
+ path = File.join(root, ".textus/manifest.yaml")
12
+ return [] unless File.exist?(path)
13
+
14
+ doc = YAML.safe_load_file(path, aliases: false) || {}
15
+ version = doc["version"]
16
+ return [] if version == "textus/3"
17
+
18
+ [{
19
+ "code" => "protocol_mismatch",
20
+ "severity" => "error",
21
+ "message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
22
+ "hint" => "Install textus 0.11.x to run the migrator, then upgrade to this version. See https://github.com/patrick204nqh/textus/blob/main/CHANGELOG.md#0110",
23
+ }]
24
+ end
25
+
26
+ # Doctor check interface: store.root is the .textus/ directory itself,
27
+ # so manifest.yaml lives directly inside it.
28
+ def call
29
+ path = File.join(store.root, "manifest.yaml")
30
+ return [] unless File.exist?(path)
31
+
32
+ doc = YAML.safe_load_file(path, aliases: false) || {}
33
+ version = doc["version"]
34
+ return [] if version == "textus/3"
35
+
36
+ [{
37
+ "code" => "protocol_mismatch",
38
+ "level" => "error",
39
+ "subject" => path,
40
+ "message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
41
+ "fix" => "Install textus 0.11.x to run the migrator, then upgrade to this version. See https://github.com/patrick204nqh/textus/blob/main/CHANGELOG.md#0110",
42
+ }]
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,18 +1,18 @@
1
1
  module Textus
2
2
  module Doctor
3
3
  class Check
4
- # Flags entries whose key is matched by two or more policy blocks of the
4
+ # Flags entries whose key is matched by two or more rule blocks of the
5
5
  # SAME specificity in the same slot (refresh / handler_allowlist /
6
6
  # promote). Ties are non-deterministic in the parser's pick step, so
7
7
  # they're a configuration smell — surface them.
8
- class PolicyAmbiguity < Check
8
+ class RuleAmbiguity < Check
9
9
  SLOTS = %i[refresh handler_allowlist promote].freeze
10
10
 
11
11
  def call
12
12
  out = []
13
- policies = store.manifest.policies
13
+ rules = store.manifest.rules
14
14
  store.manifest.entries.each do |mentry|
15
- matches = policies.explain(mentry.key)
15
+ matches = rules.explain(mentry.key)
16
16
  next if matches.length < 2
17
17
 
18
18
  SLOTS.each { |slot| out.concat(ambiguities_for(mentry, slot, matches)) }
@@ -34,10 +34,10 @@ module Textus
34
34
  def issue_for(mentry, slot, group)
35
35
  globs = group.map(&:match).sort
36
36
  {
37
- "code" => "policy.ambiguity",
37
+ "code" => "rule.ambiguity",
38
38
  "level" => "warning",
39
39
  "subject" => mentry.key,
40
- "message" => "entry '#{mentry.key}' matches #{group.length} policy blocks at the same " \
40
+ "message" => "entry '#{mentry.key}' matches #{group.length} rule blocks at the same " \
41
41
  "specificity for #{slot}: #{globs.join(", ")}",
42
42
  "fix" => "narrow one of the conflicting match: globs in .textus/manifest.yaml so a single " \
43
43
  "block wins for this key",
data/lib/textus/doctor.rb CHANGED
@@ -9,6 +9,7 @@ module Textus
9
9
  DOCTOR_CHECK_TIMEOUT_SECONDS = 2
10
10
 
11
11
  CHECKS = [
12
+ Check::ProtocolVersion,
12
13
  Check::ManifestFiles,
13
14
  Check::Schemas,
14
15
  Check::SchemaParseError,
@@ -20,7 +21,7 @@ module Textus
20
21
  Check::AuditLog,
21
22
  Check::UnownedSchemaFields,
22
23
  Check::SchemaViolations,
23
- Check::PolicyAmbiguity,
24
+ Check::RuleAmbiguity,
24
25
  Check::HandlerAllowlist,
25
26
  ].freeze
26
27
 
@@ -53,8 +54,8 @@ module Textus
53
54
  def run_registered_checks(store)
54
55
  out = []
55
56
  view = Application::Context.new(store: store, role: "human")
56
- store.registry.rpc_names(:check).each do |name|
57
- callable = store.registry.rpc_callable(:check, name)
57
+ store.registry.rpc_names(:validate).each do |name|
58
+ callable = store.registry.rpc_callable(:validate, name)
58
59
  begin
59
60
  result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: view) }
60
61
  if result.is_a?(Array)
@@ -71,7 +72,7 @@ module Textus
71
72
  rescue StandardError => e
72
73
  out << fail_issue(name, code: "doctor_check.failed",
73
74
  message: "#{e.class}: #{e.message}",
74
- fix: "fix the :check hook in .textus/hooks/")
75
+ fix: "fix the :validate hook in .textus/hooks/")
75
76
  end
76
77
  end
77
78
  out
@@ -1,14 +1,14 @@
1
1
  module Textus
2
2
  module Domain
3
- Permission = Data.define(:zone, :writable_by, :readable_by) do
3
+ Permission = Data.define(:zone, :write_policy, :read_policy) do
4
4
  def allows_write?(role)
5
- writable_by.include?(role.to_s)
5
+ write_policy.include?(role.to_s)
6
6
  end
7
7
 
8
8
  def allows_read?(role)
9
- return true if readable_by == :all
9
+ return true if [:all, ["all"]].include?(read_policy)
10
10
 
11
- readable_by.include?(role.to_s)
11
+ read_policy.include?(role.to_s)
12
12
  end
13
13
  end
14
14
  end
@@ -0,0 +1,31 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ module Predicates
5
+ class HumanAccept
6
+ attr_reader :reason
7
+
8
+ def name
9
+ "human_accept"
10
+ end
11
+
12
+ # The role is passed via `store` (an Application::Context-like object
13
+ # with a `role` reader) or through the entry metadata. In practice,
14
+ # Accept already enforces role == "human" before reaching the
15
+ # promotion gate, so this predicate trivially passes. It documents
16
+ # intent and future-proofs multi-actor accept flows.
17
+ def call(store:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
18
+ role = store.respond_to?(:role) ? store.role.to_s : nil
19
+ # If we cannot determine the role (e.g. store doesn't expose it),
20
+ # we trust that Accept has already checked — allow through.
21
+ return true if role.nil?
22
+
23
+ ok = (role == "human")
24
+ @reason = "current role is '#{role}', expected 'human'" unless ok
25
+ ok
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,50 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ module Predicates
5
+ class SchemaValid
6
+ attr_reader :reason
7
+
8
+ def name
9
+ "schema_valid"
10
+ end
11
+
12
+ def call(entry:, store:)
13
+ return true if entry.nil? || store.nil?
14
+
15
+ target_key = entry.dig("_meta", "proposal", "target_key")
16
+ return true unless target_key
17
+
18
+ mentry, = store.manifest.resolve(target_key)
19
+ schema_ref = mentry&.schema
20
+ return true unless schema_ref
21
+
22
+ schema = store.schema_for(schema_ref)
23
+ return true unless schema
24
+
25
+ frontmatter = entry.dig("_meta", "frontmatter") || {}
26
+ begin
27
+ schema.validate!(frontmatter)
28
+ rescue Textus::SchemaViolation => e
29
+ @reason = e.message.dup
30
+ d = e.details
31
+ if d.is_a?(Hash)
32
+ if d["missing"]
33
+ @reason = "missing required fields: #{Array(d["missing"]).join(", ")}"
34
+ elsif d["field"]
35
+ @reason = "field '#{d["field"]}': #{d["reason"]}"
36
+ end
37
+ end
38
+ return false
39
+ end
40
+
41
+ true
42
+ rescue StandardError => e
43
+ @reason = "schema validation error: #{e.message}"
44
+ false
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,45 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ # Promotion evaluates a list of named predicates against a pending-proposal
5
+ # entry and returns a Result indicating whether all requirements are met.
6
+ class Promotion
7
+ Result = Struct.new(:ok?, :reasons, keyword_init: true)
8
+
9
+ REGISTRY = {
10
+ "schema_valid" => -> { Predicates::SchemaValid.new },
11
+ "human_accept" => -> { Predicates::HumanAccept.new },
12
+ }.freeze
13
+
14
+ def self.from_names(names)
15
+ predicates = Array(names).map do |n|
16
+ ctor = REGISTRY[n.to_s] or raise Textus::UsageError.new(
17
+ "unknown promotion predicate: '#{n}' (known: #{REGISTRY.keys.join(", ")})",
18
+ )
19
+ ctor.call
20
+ end
21
+ new(predicates: predicates)
22
+ end
23
+
24
+ attr_reader :predicates
25
+
26
+ def initialize(predicates:)
27
+ @predicates = predicates
28
+ end
29
+
30
+ def predicate_names
31
+ @predicates.map(&:name)
32
+ end
33
+
34
+ def evaluate(entry:, store:)
35
+ reasons = []
36
+ @predicates.each do |pred|
37
+ ok = pred.call(entry: entry, store: store)
38
+ reasons << "#{pred.name}: #{pred.reason || "predicate failed"}" unless ok
39
+ end
40
+ Result.new(ok?: reasons.empty?, reasons: reasons)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
data/lib/textus/errors.rb CHANGED
@@ -34,7 +34,7 @@ module Textus
34
34
  msg += "; did you mean: #{@suggestions.join(", ")}" unless @suggestions.empty?
35
35
  hint =
36
36
  if @suggestions.empty?
37
- "run 'textus list --format=json' to see all keys"
37
+ "run 'textus list --output=json' to see all keys"
38
38
  else
39
39
  "did you mean: #{@suggestions.join(", ")}"
40
40
  end
@@ -61,6 +61,12 @@ module Textus
61
61
  end
62
62
  end
63
63
 
64
+ class BadManifest < Error
65
+ def initialize(m, hint: nil)
66
+ super("bad_manifest", m, hint: hint)
67
+ end
68
+ end
69
+
64
70
  class BadContent < Error
65
71
  def initialize(path, m)
66
72
  super(
@@ -89,7 +95,7 @@ module Textus
89
95
  if writers && !writers.empty?
90
96
  writers.join(", ")
91
97
  else
92
- "the role(s) listed in the manifest 'writable_by:'"
98
+ "the role(s) listed in the manifest 'write_policy:'"
93
99
  end
94
100
  details = { "key" => k, "zone" => z }
95
101
  details["writers"] = writers if writers
@@ -121,11 +127,12 @@ module Textus
121
127
  end
122
128
 
123
129
  class InvalidRole < Error
124
- def initialize(r)
130
+ def initialize(r, message: nil)
125
131
  super(
126
- "invalid_role", "role '#{r}' is not declared in any zone",
132
+ "invalid_role",
133
+ message || "role '#{r}' is not declared in any zone",
127
134
  details: { "role" => r },
128
- hint: "valid roles are declared in .textus/manifest.yaml under zones[].writable_by",
135
+ hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under zones[].write_policy",
129
136
  )
130
137
  end
131
138
  end
@@ -165,4 +172,16 @@ module Textus
165
172
  class ProposalError < Error
166
173
  def initialize(m) = super("proposal_error", m)
167
174
  end
175
+
176
+ class FlagRenamed < Error
177
+ def initialize(old_flag, new_flag)
178
+ super(
179
+ "flag_renamed",
180
+ "#{old_flag} was renamed in textus/3 — use #{new_flag}",
181
+ details: { "old" => old_flag, "new" => new_flag },
182
+ hint: "Use #{new_flag} instead.",
183
+ exit_code: 2,
184
+ )
185
+ end
186
+ end
168
187
  end
@@ -8,21 +8,21 @@ module Textus
8
8
  module Builtin
9
9
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
10
10
  def self.register_all
11
- Textus.hook(:intake, :json) do |store:, config:, args:|
11
+ Textus.on(:resolve_intake, :json) do |store:, config:, args:|
12
12
  _ = store
13
13
  _ = args
14
14
  data = JSON.parse(config["bytes"].to_s)
15
15
  { _meta: {}, body: YAML.dump(data) }
16
16
  end
17
17
 
18
- Textus.hook(:intake, :csv) do |store:, config:, args:|
18
+ Textus.on(:resolve_intake, :csv) do |store:, config:, args:|
19
19
  _ = store
20
20
  _ = args
21
21
  rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
22
22
  { _meta: {}, body: YAML.dump(rows) }
23
23
  end
24
24
 
25
- Textus.hook(:intake, :"markdown-links") do |store:, config:, args:|
25
+ Textus.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
26
26
  _ = store
27
27
  _ = args
28
28
  links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
@@ -31,7 +31,7 @@ module Textus
31
31
  { _meta: {}, body: YAML.dump(links) }
32
32
  end
33
33
 
34
- Textus.hook(:intake, :"ical-events") do |store:, config:, args:|
34
+ Textus.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
35
35
  _ = store
36
36
  _ = args
37
37
  events = []
@@ -50,7 +50,7 @@ module Textus
50
50
  { _meta: {}, body: YAML.dump(events) }
51
51
  end
52
52
 
53
- Textus.hook(:intake, :rss) do |store:, config:, args:|
53
+ Textus.on(:resolve_intake, :rss) do |store:, config:, args:|
54
54
  _ = store
55
55
  _ = args
56
56
  doc = REXML::Document.new(config["bytes"].to_s)
@@ -35,7 +35,7 @@ module Textus
35
35
  extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
36
36
  extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
37
37
  @audit_log.append(
38
- role: "script", verb: "event_error", key: key,
38
+ role: "runner", verb: "event_error", key: key,
39
39
  etag_before: nil, etag_after: nil, extras: extras
40
40
  )
41
41
  end
@@ -1,17 +1,10 @@
1
1
  module Textus
2
2
  module Hooks
3
3
  module Dsl
4
- EVENTS = %i[
5
- intake reduce check
6
- put deleted refreshed built published accepted
7
- mv reject loaded
8
- refresh_began refresh_failed refresh_detached
9
- ].freeze
4
+ def on(event, name, **, &blk)
5
+ raise UsageError.new("hook needs a block") unless blk
10
6
 
11
- EVENTS.each do |event|
12
- define_method(event) do |name, **opts, &blk|
13
- Loader.current_registry.register(event, name, **opts, &blk)
14
- end
7
+ Loader.current_registry.register(event, name, **, &blk)
15
8
  end
16
9
  end
17
10
  end
@@ -19,8 +19,7 @@ module Textus
19
19
  end
20
20
  end
21
21
 
22
- # Public DSL — unchanged surface
22
+ # Public DSL
23
23
  def self.with_registry(registry, &) = Hooks::Loader.with_registry(registry, &)
24
24
  def self.current_registry = Hooks::Loader.current_registry
25
- def self.hook(event, name, **, &) = Hooks::Loader.current_registry.register(event, name, **, &)
26
25
  end
@@ -3,23 +3,23 @@ module Textus
3
3
  class Registry
4
4
  EVENTS = {
5
5
  # RPC: exactly 1 handler per name; return value flows into store; failure aborts.
6
- intake: { mode: :rpc, args: %i[store config args] },
7
- reduce: { mode: :rpc, args: %i[store rows config] },
8
- check: { mode: :rpc, args: %i[store] },
6
+ resolve_intake: { mode: :rpc, args: %i[store config args] },
7
+ transform_rows: { mode: :rpc, args: %i[store rows config] },
8
+ validate: { mode: :rpc, args: %i[store] },
9
9
 
10
10
  # Pub-sub: 0..N handlers per event; return discarded; failure logged to audit.
11
- put: { mode: :pubsub, args: %i[store key envelope] },
12
- deleted: { mode: :pubsub, args: %i[store key] },
13
- refreshed: { mode: :pubsub, args: %i[store key envelope change] },
14
- built: { mode: :pubsub, args: %i[store key envelope sources] },
15
- accepted: { mode: :pubsub, args: %i[store key target_key] },
16
- published: { mode: :pubsub, args: %i[store key envelope source target] },
17
- mv: { mode: :pubsub, args: %i[store key from_key to_key envelope] },
18
- reject: { mode: :pubsub, args: %i[store key target_key] },
19
- loaded: { mode: :pubsub, args: %i[store] },
20
- refresh_began: { mode: :pubsub, args: %i[store key mode] },
11
+ entry_put: { mode: :pubsub, args: %i[store key envelope] },
12
+ entry_deleted: { mode: :pubsub, args: %i[store key] },
13
+ entry_refreshed: { mode: :pubsub, args: %i[store key envelope change] },
14
+ entry_renamed: { mode: :pubsub, args: %i[store key from_key to_key envelope] },
15
+ build_completed: { mode: :pubsub, args: %i[store key envelope sources] },
16
+ proposal_accepted: { mode: :pubsub, args: %i[store key target_key] },
17
+ proposal_rejected: { mode: :pubsub, args: %i[store key target_key] },
18
+ file_published: { mode: :pubsub, args: %i[store key envelope source target] },
19
+ store_loaded: { mode: :pubsub, args: %i[store] },
20
+ refresh_started: { mode: :pubsub, args: %i[store key mode] },
21
21
  refresh_failed: { mode: :pubsub, args: %i[store key error_class error_message] },
22
- refresh_detached: { mode: :pubsub, args: %i[store key started_at budget_ms] },
22
+ refresh_backgrounded: { mode: :pubsub, args: %i[store key started_at budget_ms] },
23
23
  }.freeze
24
24
 
25
25
  def initialize(dispatcher: nil)
@@ -29,20 +29,21 @@ module Textus
29
29
  end
30
30
 
31
31
  def register(event, name, keys: nil, &blk)
32
- spec = EVENTS[event.to_sym] or raise UsageError.new("unknown event: #{event}")
33
- shape_check!(event, spec, blk)
32
+ event_sym = event.to_sym
33
+ spec = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
34
+ shape_check!(event_sym, spec, blk)
34
35
  name = name.to_sym
35
36
 
36
37
  case spec[:mode]
37
38
  when :rpc
38
- raise UsageError.new("#{event} '#{name}' already registered") if @rpc[event.to_sym].key?(name)
39
+ raise UsageError.new("#{event_sym} '#{name}' already registered") if @rpc[event_sym].key?(name)
39
40
 
40
- @rpc[event.to_sym][name] = blk
41
+ @rpc[event_sym][name] = blk
41
42
  when :pubsub
42
- raise UsageError.new("#{event} hook '#{name}' already registered") if @pubsub[event.to_sym].any? { |h| h[:name] == name }
43
+ raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
43
44
 
44
- @pubsub[event.to_sym] << { name: name, callable: blk, keys: keys }
45
- @dispatcher&.subscribe(event, name, keys: keys, &blk)
45
+ @pubsub[event_sym] << { name: name, callable: blk, keys: keys }
46
+ @dispatcher&.subscribe(event_sym, name, keys: keys, &blk)
46
47
  end
47
48
  end
48
49
 
@@ -21,7 +21,7 @@ module Textus
21
21
 
22
22
  begin
23
23
  store = Textus::Store.new(store_root)
24
- Textus::Refresh.call(store, key, as: "script")
24
+ Textus::Refresh.call(store, key, as: "runner")
25
25
  rescue StandardError
26
26
  # Already logged via :refresh_failed; exit cleanly.
27
27
  ensure
data/lib/textus/init.rb CHANGED
@@ -2,16 +2,16 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  module Init
5
- ZONES = %w[identity working inbox review output].freeze
5
+ ZONES = %w[identity working intake review output].freeze
6
6
 
7
7
  DEFAULT_MANIFEST = <<~YAML
8
- version: textus/2
8
+ version: textus/3
9
9
  zones:
10
- - { name: identity, writable_by: [human] }
11
- - { name: working, writable_by: [human, ai, script] }
12
- - { name: inbox, writable_by: [script] }
13
- - { name: review, writable_by: [ai, human] }
14
- - { name: output, writable_by: [build] }
10
+ - { name: identity, write_policy: [human], read_policy: [all] }
11
+ - { name: working, write_policy: [human, agent, runner], read_policy: [all] }
12
+ - { name: intake, write_policy: [runner], read_policy: [all] }
13
+ - { name: review, write_policy: [agent, human], read_policy: [all] }
14
+ - { name: output, write_policy: [builder], read_policy: [all] }
15
15
  entries:
16
16
  - { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self }
17
17
  - { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true }
@@ -25,56 +25,47 @@ module Textus
25
25
  startup in alphabetical order by full path. Subdirectory names are organizational
26
26
  only — the registered event and name come from the DSL call, not the file path.
27
27
 
28
- ## Per-event sugar (preferred)
28
+ ## DSL
29
29
 
30
30
  ```ruby
31
- Textus.intake(:my_source) do |config:, args:, **|
31
+ Textus.on(:resolve_intake, :my_source) do |config:, args:, **|
32
32
  { _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
33
33
  end
34
34
 
35
- Textus.reduce(:my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
36
- Textus.check(:my_check) { |store:, **| { ok: true } }
37
- Textus.put(:my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
35
+ Textus.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
36
+ Textus.on(:validate, :my_check) { |store:, **| [] }
37
+ Textus.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
38
38
 
39
39
  # Run a side-effect every time textus writes a file to your repo:
40
- Textus.published(:notify) do |key:, target:, **|
40
+ Textus.on(:file_published, :notify) do |key:, target:, **|
41
41
  warn "wrote \#{target} (from \#{key})"
42
42
  end
43
43
  ```
44
44
 
45
45
  The intake handler above is paired with a manifest entry plus a
46
- top-level `policies:` block for freshness (ttl/on_stale live in
47
- policies, not in the entry):
46
+ top-level `rules:` block for freshness (ttl/on_stale live in
47
+ rules, not in the entry):
48
48
 
49
49
  ```yaml
50
50
  entries:
51
- - key: inbox.foo
52
- path: inbox/foo.md
53
- zone: inbox
51
+ - key: intake.foo
52
+ path: intake/foo.md
53
+ zone: intake
54
54
  intake:
55
55
  handler: my_source
56
56
 
57
- policies:
58
- - match: inbox.foo
57
+ rules:
58
+ - match: intake.foo
59
59
  refresh:
60
60
  ttl: 10m
61
61
  on_stale: timed_sync # warn | sync | timed_sync (default: warn)
62
62
  ```
63
63
 
64
- ## Low-level primitive (always available)
65
-
66
- ```ruby
67
- Textus.hook(:intake, :name) { |store:, config:, args:| ... } # bring bytes in
68
- Textus.hook(:reduce, :name) { |store:, rows:, config:| ... } # transform rows
69
- Textus.hook(:check, :name) { |store:| ... } # doctor check
70
- Textus.hook(:put, :name, keys: ["..."]) # lifecycle listener
71
- { |store:, key:, envelope:| ... }
72
- ```
73
-
74
- Events: :intake, :reduce, :check (rpc — return value used)
75
- :put, :deleted, :refreshed, :built, :accepted, :published,
76
- :mv, :reject, :loaded,
77
- :refresh_began, :refresh_failed, :refresh_detached (pub-sub — return discarded)
64
+ Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
65
+ :entry_put, :entry_deleted, :entry_refreshed, :entry_renamed,
66
+ :build_completed, :proposal_accepted, :proposal_rejected,
67
+ :file_published, :store_loaded,
68
+ :refresh_started, :refresh_failed, :refresh_backgrounded (pub-sub return discarded)
78
69
 
79
70
  See SPEC.md §5.10 for the full table.
80
71
  MD
data/lib/textus/intro.rb CHANGED
@@ -13,17 +13,17 @@ module Textus
13
13
  ZONE_PURPOSES = {
14
14
  "identity" => "slow-changing identity; human-only writes",
15
15
  "working" => "active project state; humans, AI, and scripts share this surface",
16
- "inbox" => "declared external inputs; script-refreshed via actions",
16
+ "intake" => "declared external inputs; script-refreshed via actions",
17
17
  "review" => "AI proposals awaiting human accept",
18
18
  "output" => "build-computed outputs; never hand-edited",
19
19
  }.freeze
20
20
 
21
21
  WRITE_FLOWS = {
22
22
  "human" => "edit files in identity/working zones, then 'textus put KEY --as=human'",
23
- "ai" => "propose changes by writing 'review.*' entries with --as=ai and a 'proposal:' frontmatter block; " \
24
- "a human runs 'textus accept' to apply",
25
- "script" => "refresh inbox entries with 'textus refresh KEY --as=script' (uses the entry's declared action)",
26
- "build" => "'textus build' computes output entries from projections; output files are never hand-edited",
23
+ "agent" => "propose changes by writing 'review.*' entries with --as=agent and a 'proposal:' frontmatter block; " \
24
+ "a human runs 'textus accept' to apply",
25
+ "runner" => "refresh intake entries with 'textus refresh KEY --as=runner' (uses the entry's declared action)",
26
+ "builder" => "'textus build' computes output entries from projections; output files are never hand-edited",
27
27
  }.freeze
28
28
 
29
29
  # The CLI verb catalog. Truth lives here; do not derive dynamically.
@@ -37,14 +37,14 @@ module Textus
37
37
  { "name" => "schema", "summary" => "field shape for a key family" },
38
38
  { "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
39
39
  { "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
40
- { "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key migrate'" },
40
+ { "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key normalize'" },
41
41
  { "name" => "delete", "summary" => "delete an entry; --as=<role>" },
42
42
  { "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
43
- { "name" => "refresh", "summary" => "run an action for an inbox entry" },
43
+ { "name" => "refresh", "summary" => "run an action for an intake entry" },
44
44
  { "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
45
45
  { "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
46
46
  { "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
47
- { "name" => "policy", "summary" => "inspect effective policies: 'policy list', 'policy explain KEY'" },
47
+ { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
48
48
  { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
49
49
  { "name" => "hook",
50
50
  "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
@@ -74,7 +74,7 @@ module Textus
74
74
 
75
75
  def self.entries_for(store)
76
76
  store.manifest.entries.map do |e|
77
- derived = store.manifest.zone_writers(e.zone).include?("build")
77
+ derived = store.manifest.zone_writers(e.zone).include?("builder")
78
78
  {
79
79
  "key" => e.key,
80
80
  "zone" => e.zone,