textus 0.10.5 → 0.14.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +60 -40
  3. data/CHANGELOG.md +318 -3
  4. data/README.md +34 -27
  5. data/SPEC.md +226 -145
  6. data/docs/conventions.md +8 -8
  7. data/lib/textus/application/context.rb +4 -0
  8. data/lib/textus/application/reads/blame.rb +1 -1
  9. data/lib/textus/application/reads/deps.rb +15 -0
  10. data/lib/textus/application/reads/freshness.rb +4 -4
  11. data/lib/textus/application/reads/get.rb +9 -12
  12. data/lib/textus/application/reads/list.rb +15 -0
  13. data/lib/textus/application/reads/policy_explain.rb +2 -2
  14. data/lib/textus/application/reads/published.rb +15 -0
  15. data/lib/textus/application/reads/rdeps.rb +15 -0
  16. data/lib/textus/application/reads/schema_envelope.rb +15 -0
  17. data/lib/textus/application/reads/stale.rb +15 -0
  18. data/lib/textus/application/reads/uid.rb +15 -0
  19. data/lib/textus/application/reads/validate_all.rb +15 -0
  20. data/lib/textus/application/reads/where.rb +15 -0
  21. data/lib/textus/application/refresh/all.rb +2 -2
  22. data/lib/textus/application/refresh/orchestrator.rb +1 -1
  23. data/lib/textus/application/refresh/worker.rb +8 -8
  24. data/lib/textus/application/writes/accept.rb +26 -8
  25. data/lib/textus/application/writes/build.rb +12 -49
  26. data/lib/textus/application/writes/delete.rb +1 -1
  27. data/lib/textus/application/writes/mv.rb +144 -0
  28. data/lib/textus/application/writes/publish.rb +42 -10
  29. data/lib/textus/application/writes/put.rb +1 -1
  30. data/lib/textus/application/writes/reject.rb +37 -0
  31. data/lib/textus/builder/pipeline.rb +1 -1
  32. data/lib/textus/builder/renderer/json.rb +1 -1
  33. data/lib/textus/builder/renderer/yaml.rb +1 -1
  34. data/lib/textus/cli/group/key.rb +1 -1
  35. data/lib/textus/cli/group/refresh.rb +21 -0
  36. data/lib/textus/cli/group/rule.rb +11 -0
  37. data/lib/textus/cli/verb/accept.rb +1 -2
  38. data/lib/textus/cli/verb/audit.rb +3 -3
  39. data/lib/textus/cli/verb/blame.rb +1 -2
  40. data/lib/textus/cli/verb/build.rb +6 -2
  41. data/lib/textus/cli/verb/delete.rb +1 -2
  42. data/lib/textus/cli/verb/deps.rb +1 -1
  43. data/lib/textus/cli/verb/freshness.rb +1 -2
  44. data/lib/textus/cli/verb/get.rb +2 -3
  45. data/lib/textus/cli/verb/hook_run.rb +3 -2
  46. data/lib/textus/cli/verb/hooks.rb +1 -1
  47. data/lib/textus/cli/verb/{migrate_keys.rb → key_normalize.rb} +1 -1
  48. data/lib/textus/cli/verb/list.rb +1 -1
  49. data/lib/textus/cli/verb/mv.rb +1 -1
  50. data/lib/textus/cli/verb/published.rb +1 -1
  51. data/lib/textus/cli/verb/put.rb +3 -3
  52. data/lib/textus/cli/verb/rdeps.rb +1 -1
  53. data/lib/textus/cli/verb/refresh.rb +1 -2
  54. data/lib/textus/cli/verb/reject.rb +1 -1
  55. data/lib/textus/cli/verb/{policy_explain.rb → rule_explain.rb} +2 -3
  56. data/lib/textus/cli/verb/{policy_list.rb → rule_list.rb} +3 -3
  57. data/lib/textus/cli/verb/schema.rb +1 -1
  58. data/lib/textus/cli/verb/uid.rb +1 -1
  59. data/lib/textus/cli/verb/where.rb +1 -1
  60. data/lib/textus/cli/verb.rb +9 -3
  61. data/lib/textus/cli.rb +6 -6
  62. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  63. data/lib/textus/doctor/check/illegal_keys.rb +39 -16
  64. data/lib/textus/doctor/check/intake_registration.rb +4 -4
  65. data/lib/textus/doctor/check/protocol_version.rb +47 -0
  66. data/lib/textus/doctor/check/{policy_ambiguity.rb → rule_ambiguity.rb} +6 -6
  67. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  68. data/lib/textus/doctor.rb +6 -5
  69. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  70. data/lib/textus/domain/permission.rb +4 -4
  71. data/lib/textus/domain/policy/predicates/human_accept.rb +31 -0
  72. data/lib/textus/domain/policy/predicates/schema_valid.rb +50 -0
  73. data/lib/textus/domain/policy/promotion.rb +45 -0
  74. data/lib/textus/entry/base.rb +28 -0
  75. data/lib/textus/entry/json.rb +59 -0
  76. data/lib/textus/entry/markdown.rb +46 -0
  77. data/lib/textus/entry/text.rb +35 -0
  78. data/lib/textus/entry/yaml.rb +59 -0
  79. data/lib/textus/entry.rb +16 -0
  80. data/lib/textus/envelope.rb +44 -14
  81. data/lib/textus/errors.rb +24 -5
  82. data/lib/textus/hooks/builtin.rb +5 -5
  83. data/lib/textus/hooks/dispatcher.rb +1 -1
  84. data/lib/textus/hooks/dsl.rb +3 -10
  85. data/lib/textus/hooks/loader.rb +1 -2
  86. data/lib/textus/hooks/registry.rb +22 -21
  87. data/lib/textus/infra/refresh/detached.rb +1 -1
  88. data/lib/textus/init.rb +25 -34
  89. data/lib/textus/intro.rb +65 -9
  90. data/lib/textus/manifest/entry/parser.rb +84 -0
  91. data/lib/textus/manifest/entry/validators/events.rb +21 -0
  92. data/lib/textus/manifest/entry/validators/format_matrix.rb +26 -0
  93. data/lib/textus/manifest/entry/validators/index_filename.rb +45 -0
  94. data/lib/textus/manifest/entry/validators/inject_intro.rb +18 -0
  95. data/lib/textus/manifest/entry/validators/publish_each.rb +37 -0
  96. data/lib/textus/manifest/entry/validators.rb +20 -0
  97. data/lib/textus/manifest/entry.rb +38 -189
  98. data/lib/textus/manifest/{policies.rb → rules.rb} +12 -10
  99. data/lib/textus/manifest/schema.rb +49 -0
  100. data/lib/textus/manifest.rb +50 -24
  101. data/lib/textus/migrate_keys.rb +1 -1
  102. data/lib/textus/operations/reads.rb +39 -0
  103. data/lib/textus/operations/refresh.rb +27 -0
  104. data/lib/textus/operations/writes.rb +21 -0
  105. data/lib/textus/operations.rb +44 -0
  106. data/lib/textus/projection.rb +9 -8
  107. data/lib/textus/refresh.rb +4 -5
  108. data/lib/textus/schema/tools.rb +8 -7
  109. data/lib/textus/store/reader.rb +1 -1
  110. data/lib/textus/store/staleness/intake_check.rb +1 -1
  111. data/lib/textus/store/validator.rb +3 -3
  112. data/lib/textus/store/writer.rb +5 -74
  113. data/lib/textus/store.rb +1 -55
  114. data/lib/textus/version.rb +2 -2
  115. data/lib/textus.rb +1 -0
  116. metadata +35 -10
  117. data/lib/textus/cli/group/policy.rb +0 -11
  118. data/lib/textus/composition.rb +0 -72
  119. data/lib/textus/proposal.rb +0 -10
  120. data/lib/textus/store/mover.rb +0 -167
@@ -40,6 +40,65 @@ module Textus
40
40
  end
41
41
 
42
42
  def self.extensions = [".yaml", ".yml"]
43
+
44
+ def self.nested_glob = "**/*.{yaml,yml}"
45
+
46
+ def self.serialize_for_put(meta:, body:, content:, path:)
47
+ raise UsageError.new("put for yaml requires content: or body:") if content.nil? && (body.nil? || body.to_s.empty?)
48
+
49
+ if content.nil?
50
+ begin
51
+ parsed = parse(body.to_s, path: path)
52
+ rescue BadFrontmatter => e
53
+ raise BadContent.new(path, "bad_content: #{e.message}")
54
+ end
55
+ [body.to_s, parsed["_meta"], body.to_s, parsed["content"]]
56
+ else
57
+ bytes = serialize(meta: meta, body: "", content: content)
58
+ [bytes, meta, bytes, content]
59
+ end
60
+ end
61
+
62
+ # Mutating filesystem op; returns true if a write happened.
63
+ def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
64
+ raw = File.binread(path)
65
+ parsed = parse(raw, path: path)
66
+ meta = parsed["_meta"]
67
+ return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
68
+
69
+ new_meta = meta.merge("name" => basename)
70
+ File.binwrite(path, serialize(meta: new_meta, body: "", content: parsed["content"]))
71
+ true
72
+ end
73
+
74
+ def self.enforce_name_match!(path, meta)
75
+ return unless meta.is_a?(Hash) && meta["name"]
76
+
77
+ ext = extensions.first
78
+ basename = File.basename(path, ext)
79
+ return if meta["name"] == basename
80
+
81
+ raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
82
+ end
83
+
84
+ def self.inject_uid(meta, content, existing_uid)
85
+ m = meta.is_a?(Hash) ? meta.dup : {}
86
+ m["uid"] = existing_uid || Textus::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
87
+ [m, content]
88
+ end
89
+
90
+ def self.validate_path_extension(path, nested)
91
+ ext = File.extname(path)
92
+ if nested
93
+ return if ext == ""
94
+
95
+ raise UsageError.new("nested yaml path must not have an extension")
96
+ end
97
+
98
+ return if [".yaml", ".yml"].include?(ext)
99
+
100
+ raise UsageError.new("yaml format requires '.yaml' or '.yml' path (got #{ext.inspect})")
101
+ end
43
102
  end
44
103
  end
45
104
  end
data/lib/textus/entry.rb CHANGED
@@ -10,10 +10,26 @@ module Textus
10
10
  "text" => Text,
11
11
  }.freeze
12
12
 
13
+ EXT_TO_FORMAT = {
14
+ ".md" => "markdown",
15
+ ".json" => "json",
16
+ ".yaml" => "yaml",
17
+ ".yml" => "yaml",
18
+ ".txt" => "text",
19
+ }.freeze
20
+
13
21
  def self.for_format(format)
14
22
  STRATEGIES.fetch(format.to_s) { raise UsageError.new("unknown entry format: #{format.inspect}") }
15
23
  end
16
24
 
25
+ def self.infer_from_extension(ext)
26
+ EXT_TO_FORMAT[ext]
27
+ end
28
+
29
+ def self.formats
30
+ EXT_TO_FORMAT.values.uniq
31
+ end
32
+
17
33
  def self.parse(raw, path: nil, format: "markdown")
18
34
  for_format(format).parse(raw, path: path)
19
35
  end
@@ -1,30 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Envelope
4
+ Envelope = Data.define(
5
+ :protocol, :key, :zone, :owner, :path, :format,
6
+ :uid, :etag, :schema_ref, :meta, :body, :content, :freshness
7
+ ) do
5
8
  # rubocop:disable Metrics/ParameterLists
6
- def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil)
9
+ def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
7
10
  # rubocop:enable Metrics/ParameterLists
8
- env = {
9
- "protocol" => PROTOCOL,
11
+ new(
12
+ protocol: Textus::PROTOCOL,
13
+ key: key,
14
+ zone: mentry.zone,
15
+ owner: mentry.owner,
16
+ path: path,
17
+ format: mentry.format,
18
+ uid: extract_uid(meta),
19
+ etag: etag,
20
+ schema_ref: mentry.schema,
21
+ meta: meta,
22
+ body: body,
23
+ content: content,
24
+ freshness: freshness,
25
+ )
26
+ end
27
+
28
+ def self.extract_uid(meta)
29
+ v = meta.is_a?(Hash) ? meta["uid"] : nil
30
+ v.is_a?(String) ? v : nil
31
+ end
32
+
33
+ def to_h_for_wire
34
+ h = {
35
+ "protocol" => protocol,
10
36
  "key" => key,
11
- "zone" => mentry.zone,
12
- "owner" => mentry.owner,
37
+ "zone" => zone,
38
+ "owner" => owner,
13
39
  "path" => path,
14
- "format" => mentry.format,
40
+ "format" => format,
15
41
  "_meta" => meta,
16
42
  "body" => body,
17
43
  "etag" => etag,
18
- "schema_ref" => mentry.schema,
19
- "uid" => extract_uid(meta),
44
+ "schema_ref" => schema_ref,
45
+ "uid" => uid,
20
46
  }
21
- env["content"] = content unless content.nil?
22
- env
47
+ h["content"] = content unless content.nil?
48
+ freshness.each { |k, v| h[k.to_s] = v } if freshness.is_a?(Hash)
49
+ h
23
50
  end
24
51
 
25
- def self.extract_uid(meta)
26
- v = meta.is_a?(Hash) ? meta["uid"] : nil
27
- v.is_a?(String) ? v : nil
52
+ def stale?
53
+ freshness.is_a?(Hash) && (freshness["stale"] == true || freshness[:stale] == true)
54
+ end
55
+
56
+ def refreshing?
57
+ freshness.is_a?(Hash) && (freshness["refreshing"] == true || freshness[:refreshing] == true)
28
58
  end
29
59
  end
30
60
  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,72 @@ 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
+ }.freeze
28
+
29
+ # Static, store-independent guide to the agent-facing protocol. Surfaced
30
+ # under the new top-level `agent_protocol` key in Intro.run. Recipes
31
+ # describe CLI verbs (not Ruby Operations) because the audience is an
32
+ # agent driving textus from the command line.
33
+ AGENT_PROTOCOL = {
34
+ "envelope_shape" => {
35
+ "summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
36
+ "fields" => {
37
+ "_meta" => "hash of structured frontmatter; schema-validated per entry family",
38
+ "body" => "string payload (markdown/text) or nil for json/yaml formats where body lives in _meta",
39
+ "uid" => "stable 16-char hex identifier; preserved across writes and key renames",
40
+ "etag" => "content hash; pass back on writes to detect concurrent edits",
41
+ },
42
+ "ref" => "SPEC.md §8",
43
+ },
44
+ "role_resolution" => {
45
+ "summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, default human",
46
+ "roles" => %w[human agent runner builder],
47
+ "ref" => "SPEC.md §5",
48
+ },
49
+ "recipes" => {
50
+ "read" => {
51
+ "purpose" => "find and read an entry",
52
+ "steps" => [
53
+ "textus list --zone=ZONE --prefix=PREFIX # discover keys",
54
+ "textus get KEY # returns envelope JSON",
55
+ ],
56
+ },
57
+ "write" => {
58
+ "purpose" => "create or update an entry",
59
+ "steps" => [
60
+ "textus schema get FAMILY # learn the _meta field shape",
61
+ "build an envelope JSON: {_meta: {...}, body: \"...\"}",
62
+ "echo ENVELOPE | textus put KEY --as=ROLE --stdin",
63
+ ],
64
+ },
65
+ "propose" => {
66
+ "purpose" => "agent suggests a change for human review",
67
+ "agent_steps" => [
68
+ "echo ENVELOPE | textus put review.KEY --as=agent --stdin",
69
+ ],
70
+ "human_steps" => [
71
+ "textus accept review.KEY --as=human # promotes the proposal to its target zone",
72
+ ],
73
+ },
74
+ "refresh" => {
75
+ "purpose" => "rebuild stale intake-zone caches from their declared actions",
76
+ "steps" => [
77
+ "textus freshness --zone=intake # report fresh/stale per entry",
78
+ "textus refresh stale --zone=intake --as=runner",
79
+ ],
80
+ },
81
+ },
27
82
  }.freeze
28
83
 
29
84
  # The CLI verb catalog. Truth lives here; do not derive dynamically.
@@ -37,14 +92,14 @@ module Textus
37
92
  { "name" => "schema", "summary" => "field shape for a key family" },
38
93
  { "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
39
94
  { "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
40
- { "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key migrate'" },
95
+ { "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key normalize'" },
41
96
  { "name" => "delete", "summary" => "delete an entry; --as=<role>" },
42
97
  { "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" },
98
+ { "name" => "refresh", "summary" => "run an action for an intake entry" },
44
99
  { "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
45
100
  { "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
46
101
  { "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'" },
102
+ { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
48
103
  { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
49
104
  { "name" => "hook",
50
105
  "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
@@ -59,6 +114,7 @@ module Textus
59
114
  "hooks" => hooks_for(store),
60
115
  "write_flows" => WRITE_FLOWS.dup,
61
116
  "cli_verbs" => CLI_VERBS.map(&:dup),
117
+ "agent_protocol" => AGENT_PROTOCOL,
62
118
  "docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
63
119
  }
64
120
  end
@@ -74,7 +130,7 @@ module Textus
74
130
 
75
131
  def self.entries_for(store)
76
132
  store.manifest.entries.map do |e|
77
- derived = store.manifest.zone_writers(e.zone).include?("build")
133
+ derived = store.manifest.zone_writers(e.zone).include?("builder")
78
134
  {
79
135
  "key" => e.key,
80
136
  "zone" => e.zone,