textus 0.45.1 → 0.47.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +53 -26
  4. data/SPEC.md +15 -14
  5. data/docs/architecture/README.md +6 -25
  6. data/lib/textus/boot.rb +1 -0
  7. data/lib/textus/builder/pipeline.rb +11 -42
  8. data/lib/textus/builder/renderer/markdown.rb +4 -8
  9. data/lib/textus/cli/verb/build.rb +1 -10
  10. data/lib/textus/cli/verb/init.rb +3 -1
  11. data/lib/textus/cli.rb +29 -1
  12. data/lib/textus/container.rb +3 -15
  13. data/lib/textus/contract/resources/build_lock.rb +17 -0
  14. data/lib/textus/dispatcher.rb +1 -0
  15. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  16. data/lib/textus/doctor/check/sentinels.rb +1 -1
  17. data/lib/textus/domain/policy/predicates/fresh_within.rb +6 -5
  18. data/lib/textus/envelope/io/writer.rb +34 -0
  19. data/lib/textus/etag.rb +23 -0
  20. data/lib/textus/hooks/catalog.rb +1 -0
  21. data/lib/textus/init/templates/orientation_reducer.rb +17 -0
  22. data/lib/textus/init.rb +67 -4
  23. data/lib/textus/layout.rb +8 -0
  24. data/lib/textus/maintenance/key_delete_prefix.rb +5 -4
  25. data/lib/textus/maintenance/key_mv_prefix.rb +14 -4
  26. data/lib/textus/maintenance/migrate.rb +5 -4
  27. data/lib/textus/maintenance/rule_lint.rb +1 -1
  28. data/lib/textus/maintenance/zone_mv.rb +5 -4
  29. data/lib/textus/mcp/server.rb +14 -4
  30. data/lib/textus/ports/publisher.rb +3 -2
  31. data/lib/textus/ports/sentinel_store.rb +8 -7
  32. data/lib/textus/projection.rb +4 -3
  33. data/lib/textus/read/audit.rb +1 -1
  34. data/lib/textus/read/blame.rb +1 -1
  35. data/lib/textus/read/boot.rb +1 -1
  36. data/lib/textus/read/capabilities.rb +70 -0
  37. data/lib/textus/read/deps.rb +1 -1
  38. data/lib/textus/read/doctor.rb +1 -1
  39. data/lib/textus/read/freshness.rb +1 -1
  40. data/lib/textus/read/get.rb +1 -1
  41. data/lib/textus/read/list.rb +1 -1
  42. data/lib/textus/read/published.rb +1 -1
  43. data/lib/textus/read/pulse.rb +4 -4
  44. data/lib/textus/read/rdeps.rb +1 -1
  45. data/lib/textus/read/rule_explain.rb +1 -1
  46. data/lib/textus/read/rule_list.rb +1 -1
  47. data/lib/textus/read/schema_envelope.rb +1 -1
  48. data/lib/textus/read/uid.rb +1 -1
  49. data/lib/textus/read/where.rb +1 -1
  50. data/lib/textus/session.rb +6 -5
  51. data/lib/textus/store.rb +48 -25
  52. data/lib/textus/version.rb +1 -1
  53. data/lib/textus/write/accept.rb +1 -1
  54. data/lib/textus/write/build.rb +19 -7
  55. data/lib/textus/write/delete.rb +1 -1
  56. data/lib/textus/write/fetch_all.rb +1 -1
  57. data/lib/textus/write/fetch_worker.rb +1 -1
  58. data/lib/textus/write/mv.rb +1 -1
  59. data/lib/textus/write/propose.rb +1 -1
  60. data/lib/textus/write/put.rb +1 -1
  61. data/lib/textus/write/reject.rb +1 -1
  62. data/lib/textus/write/retention_sweep.rb +1 -1
  63. metadata +4 -1
@@ -5,7 +5,7 @@ module Textus
5
5
 
6
6
  verb :list
7
7
  summary "List keys filtered by zone and/or prefix."
8
- surfaces :cli, :ruby, :mcp
8
+ surfaces :cli, :mcp
9
9
  arg :prefix, String, description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.runbooks'"
10
10
  arg :zone, String, description: "restrict to one zone by name (see `boot` zones); combine with prefix to narrow further"
11
11
  view(:cli) { |rows| { "entries" => rows } }
@@ -5,7 +5,7 @@ module Textus
5
5
 
6
6
  verb :published
7
7
  summary "List all entries that declare a publish_to target."
8
- surfaces :cli, :ruby
8
+ surfaces :cli
9
9
  cli "published"
10
10
 
11
11
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
@@ -11,7 +11,7 @@ module Textus
11
11
 
12
12
  verb :pulse
13
13
  summary "Delta since cursor — changed entries, stale, pending proposals, doctor summary."
14
- surfaces :cli, :ruby, :mcp
14
+ surfaces :cli, :mcp
15
15
  around :cursor
16
16
  arg :since, Integer, session_default: :cursor, description: "audit seq to diff from; defaults to the session cursor"
17
17
 
@@ -33,7 +33,7 @@ module Textus
33
33
  "stale" => freshness_rows.select { |r| r[:status] == :stale }.map { |r| r[:key] },
34
34
  "pending_review" => review_keys,
35
35
  "doctor" => doctor_summary,
36
- "manifest_etag" => manifest_etag,
36
+ "contract_etag" => contract_etag,
37
37
  "next_due_at" => soonest_due(freshness_rows),
38
38
  "hook_errors" => hook_errors_since(since),
39
39
  }
@@ -76,8 +76,8 @@ module Textus
76
76
  }
77
77
  end
78
78
 
79
- def manifest_etag
80
- @file_store.etag(File.join(@root, "manifest.yaml"))
79
+ def contract_etag
80
+ Textus::Etag.for_contract(@root)
81
81
  end
82
82
 
83
83
  def hook_errors_since(seq)
@@ -5,7 +5,7 @@ module Textus
5
5
 
6
6
  verb :rdeps
7
7
  summary "List the derived entries that depend on a key (reverse deps / impact set)."
8
- surfaces :cli, :ruby, :mcp
8
+ surfaces :cli, :mcp
9
9
  arg :key, String, required: true, positional: true,
10
10
  description: "dotted key whose dependents (what would be stranded if it moved) you want"
11
11
 
@@ -11,7 +11,7 @@ module Textus
11
11
 
12
12
  verb :rule_explain
13
13
  summary "Effective rules for a key. Lean {fetch, guard} by default; detail: true adds matched blocks + guard predicates."
14
- surfaces :cli, :ruby, :mcp
14
+ surfaces :cli, :mcp
15
15
  cli "rule explain"
16
16
  arg :key, String, required: true, positional: true,
17
17
  description: "dotted key whose effective rules you want (fetch ttl/action, write guard, ...)"
@@ -9,7 +9,7 @@ module Textus
9
9
 
10
10
  verb :rule_list
11
11
  summary "List every rule block in the manifest."
12
- surfaces :cli, :ruby
12
+ surfaces :cli
13
13
  cli "rule list"
14
14
  view(:cli) { |policies| { "verb" => "rule_list", "policies" => policies } }
15
15
 
@@ -5,7 +5,7 @@ module Textus
5
5
 
6
6
  verb :schema_show
7
7
  summary "Return the schema (field shape) for an entry's family, by key."
8
- surfaces :cli, :ruby, :mcp
8
+ surfaces :cli, :mcp
9
9
  cli "schema show"
10
10
  arg :key, String, required: true, positional: true,
11
11
  description: "any key in the family whose schema you want; returns required/optional fields and their types"
@@ -5,7 +5,7 @@ module Textus
5
5
 
6
6
  verb :uid
7
7
  summary "Return the stable UID of an entry without reading its body."
8
- surfaces :cli, :ruby
8
+ surfaces :cli
9
9
  cli "key uid"
10
10
  arg :key, String, required: true, positional: true, description: "entry key"
11
11
  view(:cli) { |uid, inputs| { "key" => inputs[:key], "uid" => uid } }
@@ -5,7 +5,7 @@ module Textus
5
5
 
6
6
  verb :where
7
7
  summary "Resolve a key to its zone, owner, and path without reading the body."
8
- surfaces :cli, :ruby, :mcp
8
+ surfaces :cli, :mcp
9
9
  arg :key, String, required: true, positional: true,
10
10
  description: "dotted key to locate (returns zone, owner, path; does not read content)"
11
11
 
@@ -1,16 +1,17 @@
1
1
  module Textus
2
2
  # The agent session: per-connection (MCP), per-process (CLI), or per-loop
3
- # (Ruby) orientation state — the audit cursor plus the manifest etag and
3
+ # (Ruby) orientation state — the audit cursor plus the contract etag and
4
4
  # propose_zone captured at boot. Immutable Data value; advance_cursor
5
- # returns a new instance. ADR 0036.
6
- Session = Data.define(:role, :cursor, :propose_zone, :manifest_etag) do
5
+ # returns a new instance. ADR 0036; contract_etag widened in ADR 0074.
6
+ Session = Data.define(:role, :cursor, :propose_zone, :contract_etag) do
7
7
  def advance_cursor(new_cursor) = with(cursor: new_cursor)
8
8
 
9
9
  def check_etag!(observed_etag)
10
- return if observed_etag == manifest_etag
10
+ return if observed_etag == contract_etag
11
11
 
12
12
  raise Textus::MCP::ContractDrift.new(
13
- "manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
13
+ "contract changed (manifest/hooks/schemas were #{short_etag(contract_etag)}, " \
14
+ "now #{short_etag(observed_etag)}); re-run boot",
14
15
  )
15
16
  end
16
17
 
data/lib/textus/store.rb CHANGED
@@ -2,52 +2,50 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  class Store
5
- attr_reader :root, :manifest, :schemas, :file_store, :audit_log, :events, :rpc
5
+ attr_reader :container
6
+
7
+ # Readers are derived from the Container's schema, so the field set lives
8
+ # in exactly one place (Container's Data.define). A new capability added
9
+ # there is automatically exposed on the Store.
10
+ Textus::Container.members.each do |field|
11
+ define_method(field) { @container.public_send(field) }
12
+ end
6
13
 
7
14
  def self.discover(start_dir = Dir.pwd, root: nil)
8
15
  explicit = root || ENV.fetch("TEXTUS_ROOT", nil)
9
16
  return discover_explicit(explicit) if explicit
10
17
 
11
- dir = File.expand_path(start_dir)
18
+ ascend_for_store(File.expand_path(start_dir)) ||
19
+ raise(IoError.new("no .textus directory found from #{start_dir}"))
20
+ end
21
+
22
+ private_class_method def self.ascend_for_store(dir)
12
23
  loop do
13
24
  candidate = File.join(dir, ".textus")
14
- return new(candidate) if File.directory?(candidate) && File.exist?(File.join(candidate, "manifest.yaml"))
25
+ return new(candidate) if store_dir?(candidate)
15
26
 
16
27
  parent = File.dirname(dir)
17
- break if parent == dir
28
+ return nil if parent == dir
18
29
 
19
30
  dir = parent
20
31
  end
21
- raise IoError.new("no .textus directory found from #{start_dir}")
22
32
  end
23
33
 
24
34
  private_class_method def self.discover_explicit(root_arg)
25
35
  abs = File.expand_path(root_arg)
26
- raise IoError.new("no textus store at #{abs}") unless File.directory?(abs) && File.exist?(File.join(abs, "manifest.yaml"))
36
+ raise IoError.new("no textus store at #{abs}") unless store_dir?(abs)
27
37
 
28
38
  new(abs)
29
39
  end
30
40
 
31
- def initialize(root)
32
- @root = File.expand_path(root)
33
- @manifest = Manifest.load(@root)
34
- @schemas = Schemas.new(File.join(@root, "schemas"))
35
- @file_store = Ports::Storage::FileStore.new
36
- @audit_log = Ports::AuditLog.new(
37
- @root,
38
- max_size: @manifest.data.audit_config[:max_size],
39
- keep: @manifest.data.audit_config[:keep],
40
- )
41
- @events = Hooks::EventBus.new
42
- @rpc = Hooks::RpcRegistry.new
43
- Ports::AuditSubscriber.new(@audit_log).attach(@events)
44
- Hooks::Builtin.register_all(events: @events, rpc: @rpc)
45
- Hooks::Loader.new(events: @events, rpc: @rpc).load_dir(File.join(@root, "hooks"))
46
- @events.publish(:store_loaded, ctx: Hooks::Context.new(scope: as(Role::DEFAULT)))
41
+ private_class_method def self.store_dir?(dir)
42
+ File.directory?(dir) && File.exist?(File.join(dir, "manifest.yaml"))
47
43
  end
48
44
 
49
- def container
50
- @container ||= Textus::Container.from_store(self)
45
+ def initialize(root)
46
+ @container = build_container(File.expand_path(root))
47
+ bootstrap_hooks
48
+ events.publish(:store_loaded, ctx: Hooks::Context.new(scope: as(Role::DEFAULT)))
51
49
  end
52
50
 
53
51
  # Build an agent Session oriented at the current cursor/manifest — the
@@ -57,7 +55,7 @@ module Textus
57
55
  role: role,
58
56
  cursor: audit_log.latest_seq,
59
57
  propose_zone: manifest.policy.propose_zone_for(role),
60
- manifest_etag: file_store.etag(File.join(root, "manifest.yaml")),
58
+ contract_etag: Textus::Etag.for_contract(root),
61
59
  )
62
60
  end
63
61
 
@@ -70,5 +68,30 @@ module Textus
70
68
  as(role).public_send(verb, *args, **kwargs)
71
69
  end
72
70
  end
71
+
72
+ private
73
+
74
+ def build_container(root)
75
+ manifest = Manifest.load(root)
76
+ Container.new(
77
+ root: root,
78
+ manifest: manifest,
79
+ schemas: Schemas.new(File.join(root, "schemas")),
80
+ file_store: Ports::Storage::FileStore.new,
81
+ audit_log: Ports::AuditLog.new(
82
+ root,
83
+ max_size: manifest.data.audit_config[:max_size],
84
+ keep: manifest.data.audit_config[:keep],
85
+ ),
86
+ events: Hooks::EventBus.new,
87
+ rpc: Hooks::RpcRegistry.new,
88
+ )
89
+ end
90
+
91
+ def bootstrap_hooks
92
+ Ports::AuditSubscriber.new(audit_log).attach(events)
93
+ Hooks::Builtin.register_all(events: events, rpc: rpc)
94
+ Hooks::Loader.new(events: events, rpc: rpc).load_dir(File.join(root, "hooks"))
95
+ end
73
96
  end
74
97
  end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.45.1"
2
+ VERSION = "0.47.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -5,7 +5,7 @@ module Textus
5
5
 
6
6
  verb :accept
7
7
  summary "apply a queued proposal to its target zone; requires the author capability"
8
- surfaces :cli, :ruby
8
+ surfaces :cli, :mcp
9
9
  cli "accept"
10
10
  arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
11
11
 
@@ -14,8 +14,9 @@ module Textus
14
14
 
15
15
  verb :build
16
16
  summary "materialize derived entries; publish_to and publish_tree fan out copies"
17
- surfaces :cli, :ruby
17
+ surfaces :cli, :mcp
18
18
  cli "build"
19
+ around :build_lock
19
20
  arg :prefix, String, required: false, description: "limit the build to keys under this prefix"
20
21
 
21
22
  def initialize(container:, call:)
@@ -25,10 +26,21 @@ module Textus
25
26
  end
26
27
 
27
28
  def call(prefix: nil)
29
+ build_role = @manifest.policy.actor_for("build") or
30
+ raise Textus::UsageError.new(
31
+ "no role holds the 'build' capability",
32
+ hint: "declare a role with `can: [build]` in .textus/manifest.yaml",
33
+ )
34
+ build_call = Textus::Call.build(
35
+ role: build_role,
36
+ correlation_id: @call.correlation_id,
37
+ dry_run: @call.dry_run,
38
+ )
39
+
28
40
  built = []
29
41
  leaves = []
30
42
  pruned = []
31
- context = build_context
43
+ context = build_context(build_call)
32
44
 
33
45
  @manifest.data.entries.each do |mentry|
34
46
  next if prefix && !entry_matches_prefix?(mentry, prefix)
@@ -49,11 +61,11 @@ module Textus
49
61
 
50
62
  private
51
63
 
52
- def build_context
64
+ def build_context(call)
53
65
  Textus::Manifest::Entry::Base::PublishContext.new(
54
66
  container: @container,
55
- call: @call,
56
- reader: reader,
67
+ call: call,
68
+ reader: reader(call),
57
69
  )
58
70
  end
59
71
 
@@ -70,8 +82,8 @@ module Textus
70
82
  end
71
83
  end
72
84
 
73
- def reader
74
- @reader ||= Textus::Read::Get.new(container: @container, call: @call)
85
+ def reader(call)
86
+ Textus::Read::Get.new(container: @container, call: call)
75
87
  end
76
88
  end
77
89
  end
@@ -6,7 +6,7 @@ module Textus
6
6
  verb :delete
7
7
  summary "Delete one entry by key. Single-key, lower blast radius than " \
8
8
  "key_delete_prefix; guarded by an optional optimistic-concurrency etag. Returns {ok, key, deleted}."
9
- surfaces :cli, :ruby, :mcp
9
+ surfaces :cli, :mcp
10
10
  cli "key delete"
11
11
  arg :key, String, required: true, positional: true,
12
12
  description: "dotted entry key to delete"
@@ -5,7 +5,7 @@ module Textus
5
5
 
6
6
  verb :fetch_all
7
7
  summary "Fetch all stale quarantine entries, optionally scoped by zone/prefix."
8
- surfaces :cli, :ruby, :mcp
8
+ surfaces :cli, :mcp
9
9
  cli "fetch all"
10
10
  arg :prefix, String, description: "only refresh stale entries whose key starts with this dotted prefix"
11
11
  arg :zone, String, description: "only refresh stale entries in this quarantine zone (see `pulse` stale list)"
@@ -7,7 +7,7 @@ module Textus
7
7
 
8
8
  verb :fetch
9
9
  summary "Run a fetch action for one quarantine entry."
10
- surfaces :cli, :ruby, :mcp
10
+ surfaces :cli, :mcp
11
11
  arg :key, String, required: true, positional: true,
12
12
  description: "quarantine-zone entry key to refresh using its declared intake action"
13
13
  view { |outcome| { "outcome" => outcome.class.name.split("::").last.downcase } }
@@ -5,7 +5,7 @@ module Textus
5
5
 
6
6
  verb :mv
7
7
  summary "Rename one entry (same zone + format). Refuses if the target exists. Single-key, lower blast radius than key_mv_prefix."
8
- surfaces :cli, :ruby, :mcp
8
+ surfaces :cli, :mcp
9
9
  cli "key mv"
10
10
  arg :old_key, String, required: true, positional: true,
11
11
  description: "current dotted key"
@@ -9,7 +9,7 @@ module Textus
9
9
 
10
10
  verb :propose
11
11
  summary "Write a proposal to the role's propose_zone. Auto-prefixes the key."
12
- surfaces :cli, :ruby, :mcp
12
+ surfaces :cli, :mcp
13
13
  cli_stdin :json
14
14
  arg :key, String, required: true, positional: true,
15
15
  description: "key relative to propose_zone, e.g. 'decisions.feature-x'"
@@ -5,7 +5,7 @@ module Textus
5
5
 
6
6
  verb :put
7
7
  summary "Create or update an entry. Schema-validated. Returns {uid, etag}."
8
- surfaces :cli, :ruby, :mcp
8
+ surfaces :cli, :mcp
9
9
  arg :key, String, required: true, positional: true,
10
10
  description: "dotted entry key, e.g. 'knowledge.project'; must resolve to a zone the role may write"
11
11
  arg :meta, Hash, required: false, wire_name: :_meta,
@@ -5,7 +5,7 @@ module Textus
5
5
 
6
6
  verb :reject
7
7
  summary "discard a queued proposal without applying it"
8
- surfaces :cli, :ruby
8
+ surfaces :cli, :mcp
9
9
  cli "reject"
10
10
  arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
11
11
 
@@ -11,7 +11,7 @@ module Textus
11
11
 
12
12
  verb :retain
13
13
  summary "Apply each entry's retention policy; prune expired versions."
14
- surfaces :cli, :ruby
14
+ surfaces :cli
15
15
  cli "retain"
16
16
  arg :prefix, String, description: "restrict to keys starting with this dotted prefix"
17
17
  arg :zone, String, description: "restrict to entries in this zone"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.45.1
4
+ version: 0.47.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -147,6 +147,7 @@ files:
147
147
  - lib/textus/contract.rb
148
148
  - lib/textus/contract/around.rb
149
149
  - lib/textus/contract/binder.rb
150
+ - lib/textus/contract/resources/build_lock.rb
150
151
  - lib/textus/contract/resources/cursor.rb
151
152
  - lib/textus/contract/sources.rb
152
153
  - lib/textus/contract/view.rb
@@ -222,6 +223,7 @@ files:
222
223
  - lib/textus/hooks/signature.rb
223
224
  - lib/textus/init.rb
224
225
  - lib/textus/init/templates/machine_intake.rb
226
+ - lib/textus/init/templates/orientation_reducer.rb
225
227
  - lib/textus/key/distance.rb
226
228
  - lib/textus/key/grammar.rb
227
229
  - lib/textus/key/path.rb
@@ -282,6 +284,7 @@ files:
282
284
  - lib/textus/read/audit.rb
283
285
  - lib/textus/read/blame.rb
284
286
  - lib/textus/read/boot.rb
287
+ - lib/textus/read/capabilities.rb
285
288
  - lib/textus/read/deps.rb
286
289
  - lib/textus/read/doctor.rb
287
290
  - lib/textus/read/freshness.rb