textus 0.45.1 → 0.46.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +53 -26
  4. data/SPEC.md +11 -11
  5. data/docs/architecture/README.md +4 -23
  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.rb +29 -1
  10. data/lib/textus/container.rb +3 -15
  11. data/lib/textus/dispatcher.rb +1 -0
  12. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  13. data/lib/textus/doctor/check/sentinels.rb +1 -1
  14. data/lib/textus/domain/policy/predicates/fresh_within.rb +6 -5
  15. data/lib/textus/envelope/io/writer.rb +34 -0
  16. data/lib/textus/layout.rb +8 -0
  17. data/lib/textus/maintenance/key_delete_prefix.rb +5 -4
  18. data/lib/textus/maintenance/key_mv_prefix.rb +14 -4
  19. data/lib/textus/maintenance/migrate.rb +5 -4
  20. data/lib/textus/maintenance/rule_lint.rb +1 -1
  21. data/lib/textus/maintenance/zone_mv.rb +5 -4
  22. data/lib/textus/ports/publisher.rb +3 -2
  23. data/lib/textus/ports/sentinel_store.rb +8 -7
  24. data/lib/textus/projection.rb +4 -3
  25. data/lib/textus/read/audit.rb +1 -1
  26. data/lib/textus/read/blame.rb +1 -1
  27. data/lib/textus/read/boot.rb +1 -1
  28. data/lib/textus/read/capabilities.rb +70 -0
  29. data/lib/textus/read/deps.rb +1 -1
  30. data/lib/textus/read/doctor.rb +1 -1
  31. data/lib/textus/read/freshness.rb +1 -1
  32. data/lib/textus/read/get.rb +1 -1
  33. data/lib/textus/read/list.rb +1 -1
  34. data/lib/textus/read/published.rb +1 -1
  35. data/lib/textus/read/pulse.rb +1 -1
  36. data/lib/textus/read/rdeps.rb +1 -1
  37. data/lib/textus/read/rule_explain.rb +1 -1
  38. data/lib/textus/read/rule_list.rb +1 -1
  39. data/lib/textus/read/schema_envelope.rb +1 -1
  40. data/lib/textus/read/uid.rb +1 -1
  41. data/lib/textus/read/where.rb +1 -1
  42. data/lib/textus/store.rb +47 -24
  43. data/lib/textus/version.rb +1 -1
  44. data/lib/textus/write/accept.rb +1 -1
  45. data/lib/textus/write/build.rb +1 -1
  46. data/lib/textus/write/delete.rb +1 -1
  47. data/lib/textus/write/fetch_all.rb +1 -1
  48. data/lib/textus/write/fetch_worker.rb +1 -1
  49. data/lib/textus/write/mv.rb +1 -1
  50. data/lib/textus/write/propose.rb +1 -1
  51. data/lib/textus/write/put.rb +1 -1
  52. data/lib/textus/write/reject.rb +1 -1
  53. data/lib/textus/write/retention_sweep.rb +1 -1
  54. metadata +2 -1
@@ -6,11 +6,12 @@ module Textus
6
6
 
7
7
  verb :key_delete_prefix
8
8
  summary "Bulk-delete every leaf key under prefix."
9
- surfaces :cli, :ruby, :mcp
9
+ surfaces :cli, :mcp
10
10
  cli "key delete-prefix"
11
11
  arg :prefix, String, required: true, positional: true, description: "every leaf key under this dotted prefix is deleted"
12
- arg :dry_run, :boolean, default: true, cli_default: false,
13
- description: "agents plan by default; the CLI applies by default (pass --dry-run to plan only)"
12
+ arg :dry_run, :boolean, default: false,
13
+ description: "when true, returns the keys that would be deleted without deleting them; " \
14
+ "defaults to false, so omitting it deletes immediately"
14
15
  view { |v, _i| v.to_h }
15
16
 
16
17
  def initialize(container:, call:)
@@ -18,7 +19,7 @@ module Textus
18
19
  @call = call
19
20
  end
20
21
 
21
- def call(prefix, dry_run: true)
22
+ def call(prefix, dry_run: false)
22
23
  raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
23
24
 
24
25
  leaves = Read::List.new(container: @container)
@@ -7,12 +7,13 @@ module Textus
7
7
 
8
8
  verb :key_mv_prefix
9
9
  summary "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false."
10
- surfaces :cli, :ruby, :mcp
10
+ surfaces :cli, :mcp
11
11
  cli "key mv-prefix"
12
12
  arg :from_prefix, String, required: true, positional: true, description: "dotted prefix whose leaf keys are renamed"
13
13
  arg :to_prefix, String, required: true, positional: true, description: "dotted prefix the keys are renamed to"
14
- arg :dry_run, :boolean, default: true, cli_default: false,
15
- description: "agents plan by default; the CLI applies by default (pass --dry-run to plan only)"
14
+ arg :dry_run, :boolean, default: false,
15
+ description: "when true, returns the planned moves without applying them; " \
16
+ "defaults to false, so omitting it applies the rename immediately"
16
17
  view { |v, _i| v.to_h }
17
18
 
18
19
  def initialize(container:, call:)
@@ -20,10 +21,19 @@ module Textus
20
21
  @call = call
21
22
  end
22
23
 
23
- def call(from_prefix, to_prefix, dry_run: true)
24
+ def call(from_prefix, to_prefix, dry_run: false)
24
25
  raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
25
26
 
26
27
  leaves = list_leaves_under(from_prefix)
28
+
29
+ # When from_prefix is itself a leaf, `delete_prefix("#{from_prefix}.")`
30
+ # finds no trailing dot to strip, so the tail keeps the whole key and the
31
+ # move silently targets "to_prefix.<full-from_prefix>". Refuse it — a
32
+ # single-key rename is `mv`'s job, not the bulk prefix verb's.
33
+ if leaves.include?(from_prefix)
34
+ raise UsageError.new("from_prefix '#{from_prefix}' is itself a leaf — use `mv` to rename a single key")
35
+ end
36
+
27
37
  warnings = []
28
38
  warnings << "no keys under #{from_prefix}" if leaves.empty?
29
39
 
@@ -9,11 +9,12 @@ module Textus
9
9
 
10
10
  verb :migrate
11
11
  summary "Run a YAML migration plan (multi-op)."
12
- surfaces :cli, :ruby, :mcp
12
+ surfaces :cli, :mcp
13
13
  arg :plan_yaml, String, required: true, positional: true, source: :file,
14
14
  description: "path to the YAML migration plan (zone_mv, key_mv_prefix, key_delete_prefix ops run in order)"
15
- arg :dry_run, :boolean, default: true, cli_default: false,
16
- description: "agents plan by default; the CLI applies by default (pass --dry-run to plan only)"
15
+ arg :dry_run, :boolean, default: false,
16
+ description: "when true, returns the planned ops without applying them; " \
17
+ "defaults to false, so omitting it runs the migration immediately"
17
18
  view { |v, _i| v.to_h }
18
19
 
19
20
  def initialize(container:, call:)
@@ -21,7 +22,7 @@ module Textus
21
22
  @call = call
22
23
  end
23
24
 
24
- def call(plan_yaml, dry_run: true)
25
+ def call(plan_yaml, dry_run: false)
25
26
  raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
26
27
  raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
27
28
 
@@ -10,7 +10,7 @@ module Textus
10
10
 
11
11
  verb :rule_lint
12
12
  summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
13
- surfaces :cli, :ruby, :mcp
13
+ surfaces :cli, :mcp
14
14
  cli "rule lint"
15
15
  arg :candidate_yaml, String, required: true, wire_name: :against, source: :file,
16
16
  description: "path to candidate manifest YAML; its `rules:` block is diffed against the live manifest"
@@ -10,12 +10,13 @@ module Textus
10
10
 
11
11
  verb :zone_mv
12
12
  summary "Rename a zone — manifest + files. Refuses if destination exists."
13
- surfaces :cli, :ruby, :mcp
13
+ surfaces :cli, :mcp
14
14
  cli "zone mv"
15
15
  arg :from, String, required: true, positional: true, description: "current zone name"
16
16
  arg :to, String, required: true, positional: true, description: "new zone name; refused if a zone by this name already exists"
17
- arg :dry_run, :boolean, default: true, cli_default: false,
18
- description: "agents plan by default; the CLI applies by default (pass --dry-run to plan only)"
17
+ arg :dry_run, :boolean, default: false,
18
+ description: "when true, returns the planned zone move without applying it; " \
19
+ "defaults to false, so omitting it applies the move immediately"
19
20
  view { |v, _i| v.to_h }
20
21
 
21
22
  def initialize(container:, call:)
@@ -25,7 +26,7 @@ module Textus
25
26
  @root = container.root
26
27
  end
27
28
 
28
- def call(from, to, dry_run: true)
29
+ def call(from, to, dry_run: false)
29
30
  raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
30
31
  raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
31
32
 
@@ -7,8 +7,9 @@ module Textus
7
7
  # artifact; no parsing or stripping.
8
8
  #
9
9
  # Sentinel I/O is delegated to Textus::Ports::SentinelStore. Sentinels live
10
- # under `<store_root>/sentinels/` and mirror the target's repo-relative layout
11
- # so consumer directories aren't polluted with `.textus-managed.json` siblings.
10
+ # under `<store_root>/.run/sentinels/` (runtime, git-ignored ADR 0070) and
11
+ # mirror the target's repo-relative layout so consumer directories aren't
12
+ # polluted with `.textus-managed.json` siblings.
12
13
  module Publisher
13
14
  def self.publish(source:, target:, store_root:)
14
15
  FileUtils.mkdir_p(File.dirname(target))
@@ -5,12 +5,12 @@ require "fileutils"
5
5
  module Textus
6
6
  module Ports
7
7
  # Persistence adapter for sentinel files. Owns the on-disk JSON shape, the
8
- # path layout (<store_root>/sentinels/<target-rel-to-repo>.textus-managed.json),
9
- # and all File/FileUtils I/O. Domain::Sentinel is a pure value object that
10
- # depends on this port for reads and writes.
8
+ # path layout (<store_root>/.run/sentinels/<target-rel-to-repo>.textus-managed.json
9
+ # — runtime, git-ignored, ADR 0070), and all File/FileUtils I/O.
10
+ # Domain::Sentinel is a pure value object that depends on this port for
11
+ # reads and writes.
11
12
  class SentinelStore
12
13
  SUFFIX = ".textus-managed.json".freeze
13
- DIR = "sentinels".freeze
14
14
 
15
15
  def write!(target:, source:, store_root:)
16
16
  path = sentinel_path(target, store_root)
@@ -39,17 +39,18 @@ module Textus
39
39
  def sentinel_path(target, store_root)
40
40
  repo_root = File.dirname(store_root)
41
41
  rel = relative_to(target, repo_root) || File.basename(target)
42
- File.join(store_root, DIR, rel + SUFFIX)
42
+ File.join(Textus::Layout.sentinels(store_root), rel + SUFFIX)
43
43
  end
44
44
 
45
45
  # Absolute target paths of every sentinel recorded under `target_dir`.
46
46
  def targets_under(target_dir, store_root)
47
47
  repo_root = File.dirname(store_root)
48
48
  rel = relative_to(target_dir, repo_root) or return []
49
- sdir = File.join(store_root, DIR, rel)
49
+ root = Textus::Layout.sentinels(store_root)
50
+ sdir = File.join(root, rel)
50
51
  return [] unless File.directory?(sdir)
51
52
 
52
- prefix = File.join(store_root, DIR) + "/"
53
+ prefix = root + "/"
53
54
  Dir.glob(File.join(sdir, "**", "*#{SUFFIX}")).map do |spath|
54
55
  # strip the sentinel-store prefix and the .textus-managed.json suffix to recover the repo-relative target path
55
56
  trel = spath.delete_prefix(prefix).delete_suffix(SUFFIX)
@@ -33,15 +33,16 @@ module Textus
33
33
  reduced = apply_reducer(rows)
34
34
  # Reducers may return either an Array of rows (legacy / templated builds)
35
35
  # or a Hash that becomes the structured-format payload base. In the Hash
36
- # case, downstream sort/limit/position markers don't apply, and the
37
- # builder owns `_meta.generated_at` so we don't stamp it here.
36
+ # case, downstream sort/limit/position markers don't apply.
38
37
  return reduced if reduced.is_a?(Hash)
39
38
 
40
39
  rows = reduced
41
40
  rows = sort(rows)
42
41
  rows = rows.first(@limit)
43
42
  mark_positions(rows)
44
- { "entries" => rows, "count" => rows.length, "generated_at" => Time.now.utc.iso8601 }
43
+ # No `generated_at` in the payload the built artifact is content-addressed
44
+ # (ADR 0070); volatile build time is kept out of the tracked output.
45
+ { "entries" => rows, "count" => rows.length }
45
46
  end
46
47
 
47
48
  private
@@ -34,7 +34,7 @@ module Textus
34
34
 
35
35
  verb :audit
36
36
  summary "Query the audit log with optional filters."
37
- surfaces :cli, :ruby
37
+ surfaces :cli
38
38
  cli "audit"
39
39
  # #call(**filters) — args map to Query.build keyword params (ADR 0063)
40
40
  arg :key, String, required: false, description: "filter to rows for this key"
@@ -11,7 +11,7 @@ module Textus
11
11
 
12
12
  verb :blame
13
13
  summary "Annotate audit rows for a key with the git commit that introduced each file state."
14
- surfaces :cli, :ruby
14
+ surfaces :cli
15
15
  cli "blame"
16
16
  arg :key, String, required: true, positional: true, description: "entry key to blame"
17
17
  arg :limit, Integer, required: false, description: "maximum number of audit rows to return"
@@ -9,7 +9,7 @@ module Textus
9
9
 
10
10
  verb :boot
11
11
  summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
12
- surfaces :cli, :ruby, :mcp
12
+ surfaces :cli, :mcp
13
13
 
14
14
  def initialize(container:, call:)
15
15
  @container = container
@@ -0,0 +1,70 @@
1
+ module Textus
2
+ module Read
3
+ # A machine-readable projection of the contract surface: every verb, the
4
+ # transports it reaches, and its full argument schema — sourced from the
5
+ # same Contract DSL the CLI/MCP/boot already project from (ADR 0039/0063).
6
+ #
7
+ # Integrators assert their docs against this in CI so they can't drift
8
+ # (#161 F4 — patrick-nexus docs claimed "MCP exposes 3 verbs" while ~20 are
9
+ # surfaced). It also makes the per-surface `dry_run` default asymmetry
10
+ # (#161 F6) self-documenting: each arg carries both `default` (agent wire)
11
+ # and `cli_default` (CLI), so the divergence is visible, not folklore.
12
+ #
13
+ # Pure contract introspection — it reads no store data; `container` is
14
+ # accepted only for the uniform use-case constructor.
15
+ class Capabilities
16
+ extend Textus::Contract::DSL
17
+
18
+ verb :capabilities
19
+ summary "Machine-readable contract surface: every verb, its transports, and arg schema."
20
+ surfaces :cli, :mcp
21
+ arg :verb, String, required: false, description: "filter to a single verb by name"
22
+ view { |result, _i| result }
23
+
24
+ def initialize(container: nil, call: nil); end
25
+
26
+ def call(verb: nil)
27
+ klasses = Textus::Dispatcher::VERBS.values.select { |k| contract?(k) }
28
+ rows = klasses.map { |k| project(k.contract) }
29
+ rows.select! { |r| r["verb"] == verb } if verb
30
+ { "verbs" => rows.sort_by { |r| r["verb"] } }
31
+ end
32
+
33
+ private
34
+
35
+ def contract?(klass)
36
+ klass.respond_to?(:contract?) && klass.contract?
37
+ end
38
+
39
+ def project(spec)
40
+ {
41
+ "verb" => spec.verb.to_s,
42
+ "summary" => spec.summary,
43
+ "surfaces" => spec.surfaces.map(&:to_s) + ["ruby"],
44
+ "cli" => spec.cli? ? spec.cli_path : nil,
45
+ "args" => spec.args.map { |a| project_arg(a) },
46
+ }
47
+ end
48
+
49
+ def project_arg(arg)
50
+ out = {
51
+ "name" => arg.wire.to_s,
52
+ "type" => json_type(arg.type),
53
+ "required" => arg.required,
54
+ "positional" => arg.positional,
55
+ }
56
+ out["description"] = arg.description if arg.description
57
+ out["default"] = arg.default unless arg.default.nil?
58
+ out["cli_default"] = arg.cli_default unless arg.cli_default == :__unset
59
+ out["session_default"] = arg.session_default.to_s if arg.session_default
60
+ out
61
+ end
62
+
63
+ def json_type(type)
64
+ Textus::Contract.json_type(type)
65
+ rescue ArgumentError
66
+ "string"
67
+ end
68
+ end
69
+ end
70
+ end
@@ -5,7 +5,7 @@ module Textus
5
5
 
6
6
  verb :deps
7
7
  summary "List the keys a derived entry depends on (its projection/external sources)."
8
- surfaces :cli, :ruby, :mcp
8
+ surfaces :cli, :mcp
9
9
  arg :key, String, required: true, positional: true,
10
10
  description: "dotted key of the derived entry whose source keys you want"
11
11
 
@@ -10,7 +10,7 @@ module Textus
10
10
 
11
11
  verb :doctor
12
12
  summary "Run health checks on the textus store and report any issues."
13
- surfaces :cli, :ruby
13
+ surfaces :cli
14
14
  cli "doctor"
15
15
  arg :checks, Array, required: false, description: "subset of check names to run (default: all)"
16
16
 
@@ -11,7 +11,7 @@ module Textus
11
11
 
12
12
  verb :freshness
13
13
  summary "Report the fetch-freshness status of every entry with a fetch policy."
14
- surfaces :cli, :ruby
14
+ surfaces :cli
15
15
  cli "freshness"
16
16
  arg :prefix, String, required: false, description: "filter to keys with this prefix"
17
17
  arg :zone, String, required: false, description: "filter to entries in this zone"
@@ -23,7 +23,7 @@ module Textus
23
23
  "the entry's fetch rule, degrading to a pure read when the key " \
24
24
  "has no rule. Pass fetch:false for a guaranteed pure on-disk " \
25
25
  "read. Returns the envelope (uid, etag, _meta, body, freshness)."
26
- surfaces :cli, :ruby, :mcp
26
+ surfaces :cli, :mcp
27
27
  arg :key, String, required: true, positional: true,
28
28
  description: "dotted entry key to read, e.g. 'knowledge.project'"
29
29
  arg :fetch, :boolean, default: true,
@@ -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
 
@@ -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
 
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
@@ -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.46.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,7 +14,7 @@ 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
18
18
  cli "build"
19
19
  arg :prefix, String, required: false, description: "limit the build to keys under this prefix"
20
20
 
@@ -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