textus 0.10.5 → 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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -3
  3. data/README.md +39 -26
  4. data/SPEC.md +222 -144
  5. data/lib/textus/application/reads/freshness.rb +2 -2
  6. data/lib/textus/application/reads/get.rb +1 -1
  7. data/lib/textus/application/reads/policy_explain.rb +2 -2
  8. data/lib/textus/application/refresh/orchestrator.rb +1 -1
  9. data/lib/textus/application/refresh/worker.rb +5 -5
  10. data/lib/textus/application/writes/accept.rb +19 -1
  11. data/lib/textus/application/writes/build.rb +5 -5
  12. data/lib/textus/application/writes/delete.rb +1 -1
  13. data/lib/textus/application/writes/publish.rb +1 -1
  14. data/lib/textus/application/writes/put.rb +1 -1
  15. data/lib/textus/builder/pipeline.rb +1 -1
  16. data/lib/textus/builder/renderer/json.rb +1 -1
  17. data/lib/textus/builder/renderer/yaml.rb +1 -1
  18. data/lib/textus/cli/group/key.rb +1 -1
  19. data/lib/textus/cli/group/refresh.rb +21 -0
  20. data/lib/textus/cli/group/rule.rb +11 -0
  21. data/lib/textus/cli/verb/build.rb +1 -1
  22. data/lib/textus/cli/verb/hook_run.rb +3 -2
  23. data/lib/textus/cli/verb/hooks.rb +1 -1
  24. data/lib/textus/cli/verb/{migrate_keys.rb → key_normalize.rb} +1 -1
  25. data/lib/textus/cli/verb/put.rb +1 -1
  26. data/lib/textus/cli/verb/{policy_explain.rb → rule_explain.rb} +1 -1
  27. data/lib/textus/cli/verb/{policy_list.rb → rule_list.rb} +3 -3
  28. data/lib/textus/cli/verb.rb +3 -2
  29. data/lib/textus/cli.rb +6 -6
  30. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  31. data/lib/textus/doctor/check/illegal_keys.rb +39 -16
  32. data/lib/textus/doctor/check/intake_registration.rb +4 -4
  33. data/lib/textus/doctor/check/protocol_version.rb +47 -0
  34. data/lib/textus/doctor/check/{policy_ambiguity.rb → rule_ambiguity.rb} +6 -6
  35. data/lib/textus/doctor.rb +5 -4
  36. data/lib/textus/domain/permission.rb +4 -4
  37. data/lib/textus/domain/policy/predicates/human_accept.rb +31 -0
  38. data/lib/textus/domain/policy/predicates/schema_valid.rb +50 -0
  39. data/lib/textus/domain/policy/promotion.rb +45 -0
  40. data/lib/textus/errors.rb +24 -5
  41. data/lib/textus/hooks/builtin.rb +5 -5
  42. data/lib/textus/hooks/dispatcher.rb +1 -1
  43. data/lib/textus/hooks/dsl.rb +3 -10
  44. data/lib/textus/hooks/loader.rb +1 -2
  45. data/lib/textus/hooks/registry.rb +22 -21
  46. data/lib/textus/infra/refresh/detached.rb +1 -1
  47. data/lib/textus/init.rb +25 -34
  48. data/lib/textus/intro.rb +9 -9
  49. data/lib/textus/manifest/entry.rb +33 -6
  50. data/lib/textus/manifest/{policies.rb → rules.rb} +12 -10
  51. data/lib/textus/manifest/schema.rb +49 -0
  52. data/lib/textus/manifest.rb +45 -9
  53. data/lib/textus/migrate_keys.rb +1 -1
  54. data/lib/textus/projection.rb +4 -4
  55. data/lib/textus/refresh.rb +1 -1
  56. data/lib/textus/store/mover.rb +1 -1
  57. data/lib/textus/store/staleness/intake_check.rb +1 -1
  58. data/lib/textus/store/writer.rb +1 -1
  59. data/lib/textus/store.rb +1 -1
  60. data/lib/textus/version.rb +2 -2
  61. data/lib/textus.rb +1 -0
  62. metadata +13 -7
  63. data/lib/textus/cli/group/policy.rb +0 -11
@@ -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,
@@ -4,10 +4,12 @@ module Textus
4
4
  PUBLISH_EACH_VARS = %w[leaf basename key ext].freeze
5
5
  PUBLISH_EACH_VAR_RE = /\{([a-z]+)\}/
6
6
 
7
+ COMPUTE_KINDS = %w[projection external].freeze
8
+
7
9
  attr_reader :key, :path, :zone, :schema, :owner, :nested, :generator, :raw, :format,
8
10
  :projection, :template, :publish_to, :publish_each,
9
11
  :intake_handler, :intake_config,
10
- :events, :inject_intro, :index_filename
12
+ :events, :inject_intro, :index_filename, :compute
11
13
 
12
14
  def initialize(manifest, raw)
13
15
  @manifest = manifest
@@ -18,8 +20,7 @@ module Textus
18
20
  @schema = raw["schema"]
19
21
  @owner = raw["owner"]
20
22
  @nested = raw["nested"] == true
21
- @generator = raw["generator"]
22
- @projection = raw["projection"]
23
+ parse_compute!(raw)
23
24
  @template = raw["template"]
24
25
  @publish_to = Array(raw["publish_to"])
25
26
  @publish_each = raw["publish_each"]
@@ -56,14 +57,14 @@ module Textus
56
57
  end
57
58
 
58
59
  # Signal-based zone-kind predicates: derive the "kind" of a zone from its
59
- # writable_by signals rather than its literal name, so detection keeps
60
+ # write_policy signals rather than its literal name, so detection keeps
60
61
  # working when users rename the default zones.
61
62
  def in_generator_zone?
62
- zone_writers.include?("build")
63
+ zone_writers.include?("builder")
63
64
  end
64
65
 
65
66
  def in_proposal_zone?
66
- zone_writers.include?("ai")
67
+ zone_writers.include?("agent")
67
68
  end
68
69
 
69
70
  private
@@ -211,6 +212,32 @@ module Textus
211
212
  end
212
213
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
213
214
 
215
+ def parse_compute!(raw)
216
+ src = raw["compute"]
217
+ unless src
218
+ @compute = nil
219
+ @projection = nil
220
+ @generator = nil
221
+ return
222
+ end
223
+
224
+ kind = src["kind"]
225
+ unless COMPUTE_KINDS.include?(kind)
226
+ raise BadManifest.new(
227
+ "entry '#{@key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{kind.inspect})",
228
+ )
229
+ end
230
+
231
+ @compute = src.freeze
232
+ if kind == "projection"
233
+ @projection = @compute
234
+ @generator = nil
235
+ else
236
+ @generator = @compute
237
+ @projection = nil
238
+ end
239
+ end
240
+
214
241
  def parse_intake!(src)
215
242
  src ||= {}
216
243
  @intake_handler = src["handler"]
@@ -1,8 +1,8 @@
1
1
  module Textus
2
2
  class Manifest
3
- class Policies
4
- PolicySet = Data.define(:refresh, :handler_allowlist, :promote, :retention)
5
- EMPTY_SET = PolicySet.new(refresh: nil, handler_allowlist: nil, promote: nil, retention: nil)
3
+ class Rules
4
+ RuleSet = Data.define(:refresh, :handler_allowlist, :promote, :retention)
5
+ EMPTY_SET = RuleSet.new(refresh: nil, handler_allowlist: nil, promote: nil, retention: nil)
6
6
 
7
7
  def self.parse(raw)
8
8
  new(Array(raw).map { |b| Block.new(b) })
@@ -21,7 +21,7 @@ module Textus
21
21
 
22
22
  slots.each_key { |slot| slots[slot] << b if b.public_send(slot) }
23
23
  end
24
- PolicySet.new(
24
+ RuleSet.new(
25
25
  refresh: pick(slots[:refresh], :refresh, key),
26
26
  handler_allowlist: pick(slots[:handler_allowlist], :handler_allowlist, key),
27
27
  promote: pick(slots[:promote], :promote, key),
@@ -47,10 +47,10 @@ module Textus
47
47
  attr_reader :match, :refresh, :handler_allowlist, :promote, :retention
48
48
 
49
49
  def initialize(raw)
50
- @match = raw["match"] or raise Textus::UsageError.new("policy block missing match:")
50
+ @match = raw["match"] or raise Textus::UsageError.new("rule block missing match:")
51
51
  @refresh = parse_refresh(raw["refresh"])
52
- @handler_allowlist = parse_handler_allowlist(raw["handler_allowlist"])
53
- @promote = parse_promote(raw["promote_requires"])
52
+ @handler_allowlist = parse_handler_allowlist(raw["intake_handler_allowlist"])
53
+ @promote = parse_promotion(raw["promotion"])
54
54
  @retention = raw["retention"] # reserved — passthrough only
55
55
  end
56
56
 
@@ -72,10 +72,12 @@ module Textus
72
72
  Textus::Domain::Policy::HandlerAllowlist.new(handlers: arr)
73
73
  end
74
74
 
75
- def parse_promote(arr)
76
- return nil if arr.nil?
75
+ def parse_promotion(h)
76
+ return nil if h.nil?
77
+
78
+ raise Textus::BadManifest.new("promotion: must be a hash with a 'requires:' array") unless h.is_a?(Hash) && h.key?("requires")
77
79
 
78
- Textus::Domain::Policy::Promote.new(requires: arr)
80
+ Textus::Domain::Policy::Promote.new(requires: Array(h["requires"]))
79
81
  end
80
82
  end
81
83
  end