textus 0.43.2 → 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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +56 -29
  4. data/SPEC.md +24 -22
  5. data/docs/architecture/README.md +32 -32
  6. data/docs/reference/conventions.md +8 -9
  7. data/lib/textus/boot.rb +4 -4
  8. data/lib/textus/builder/pipeline.rb +11 -42
  9. data/lib/textus/builder/renderer/markdown.rb +4 -8
  10. data/lib/textus/cli/group/fetch.rb +2 -2
  11. data/lib/textus/cli/group.rb +1 -0
  12. data/lib/textus/cli/runner.rb +187 -0
  13. data/lib/textus/cli/verb/build.rb +4 -4
  14. data/lib/textus/cli/verb/{fetch_stale.rb → fetch_all.rb} +2 -2
  15. data/lib/textus/cli/verb/get.rb +6 -5
  16. data/lib/textus/cli/verb/put.rb +3 -3
  17. data/lib/textus/cli/verb.rb +3 -0
  18. data/lib/textus/cli.rb +37 -3
  19. data/lib/textus/container.rb +3 -15
  20. data/lib/textus/contract/around.rb +29 -0
  21. data/lib/textus/contract/binder.rb +88 -0
  22. data/lib/textus/contract/resources/cursor.rb +26 -0
  23. data/lib/textus/contract/sources.rb +39 -0
  24. data/lib/textus/contract/view.rb +15 -0
  25. data/lib/textus/contract.rb +68 -8
  26. data/lib/textus/dispatcher.rb +6 -6
  27. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  28. data/lib/textus/doctor/check/sentinels.rb +1 -1
  29. data/lib/textus/domain/policy/predicates/fresh_within.rb +6 -5
  30. data/lib/textus/envelope/io/writer.rb +34 -0
  31. data/lib/textus/hooks/context.rb +24 -2
  32. data/lib/textus/layout.rb +8 -0
  33. data/lib/textus/maintenance/key_delete_prefix.rb +8 -5
  34. data/lib/textus/maintenance/key_mv_prefix.rb +18 -6
  35. data/lib/textus/maintenance/migrate.rb +14 -10
  36. data/lib/textus/maintenance/rule_lint.rb +5 -4
  37. data/lib/textus/maintenance/zone_mv.rb +9 -6
  38. data/lib/textus/manifest/entry/base.rb +1 -1
  39. data/lib/textus/mcp/catalog.rb +6 -33
  40. data/lib/textus/ports/publisher.rb +3 -2
  41. data/lib/textus/ports/sentinel_store.rb +8 -7
  42. data/lib/textus/projection.rb +6 -5
  43. data/lib/textus/read/audit.rb +19 -0
  44. data/lib/textus/read/blame.rb +11 -1
  45. data/lib/textus/read/boot.rb +1 -1
  46. data/lib/textus/read/capabilities.rb +70 -0
  47. data/lib/textus/read/deps.rb +15 -1
  48. data/lib/textus/read/doctor.rb +8 -0
  49. data/lib/textus/read/freshness.rb +10 -0
  50. data/lib/textus/read/get.rb +87 -22
  51. data/lib/textus/read/list.rb +2 -1
  52. data/lib/textus/read/published.rb +7 -0
  53. data/lib/textus/read/pulse.rb +2 -1
  54. data/lib/textus/read/rdeps.rb +14 -0
  55. data/lib/textus/read/rule_explain.rb +84 -0
  56. data/lib/textus/read/rule_list.rb +39 -0
  57. data/lib/textus/read/schema_envelope.rb +3 -2
  58. data/lib/textus/read/uid.rb +9 -0
  59. data/lib/textus/read/where.rb +8 -0
  60. data/lib/textus/role_scope.rb +34 -6
  61. data/lib/textus/schema/tools.rb +12 -3
  62. data/lib/textus/store.rb +47 -24
  63. data/lib/textus/version.rb +1 -1
  64. data/lib/textus/write/accept.rb +8 -0
  65. data/lib/textus/write/{publish.rb → build.rb} +16 -7
  66. data/lib/textus/write/delete.rb +13 -0
  67. data/lib/textus/write/fetch_all.rb +2 -1
  68. data/lib/textus/write/fetch_orchestrator.rb +1 -1
  69. data/lib/textus/write/fetch_worker.rb +2 -2
  70. data/lib/textus/write/mv.rb +16 -0
  71. data/lib/textus/write/propose.rb +8 -3
  72. data/lib/textus/write/put.rb +3 -3
  73. data/lib/textus/write/reject.rb +8 -0
  74. data/lib/textus/write/retention_sweep.rb +9 -0
  75. metadata +12 -29
  76. data/lib/textus/cli/verb/accept.rb +0 -16
  77. data/lib/textus/cli/verb/audit.rb +0 -34
  78. data/lib/textus/cli/verb/blame.rb +0 -17
  79. data/lib/textus/cli/verb/delete.rb +0 -17
  80. data/lib/textus/cli/verb/deps.rb +0 -14
  81. data/lib/textus/cli/verb/freshness.rb +0 -17
  82. data/lib/textus/cli/verb/key_delete.rb +0 -24
  83. data/lib/textus/cli/verb/list.rb +0 -16
  84. data/lib/textus/cli/verb/migrate.rb +0 -18
  85. data/lib/textus/cli/verb/mv.rb +0 -27
  86. data/lib/textus/cli/verb/propose.rb +0 -28
  87. data/lib/textus/cli/verb/published.rb +0 -13
  88. data/lib/textus/cli/verb/pulse.rb +0 -26
  89. data/lib/textus/cli/verb/rdeps.rb +0 -14
  90. data/lib/textus/cli/verb/reject.rb +0 -16
  91. data/lib/textus/cli/verb/retain.rb +0 -19
  92. data/lib/textus/cli/verb/rule_explain.rb +0 -16
  93. data/lib/textus/cli/verb/rule_lint.rb +0 -18
  94. data/lib/textus/cli/verb/rule_list.rb +0 -29
  95. data/lib/textus/cli/verb/schema.rb +0 -15
  96. data/lib/textus/cli/verb/uid.rb +0 -15
  97. data/lib/textus/cli/verb/where.rb +0 -14
  98. data/lib/textus/cli/verb/zone_mv.rb +0 -19
  99. data/lib/textus/read/get_or_fetch.rb +0 -69
  100. data/lib/textus/read/policy_explain.rb +0 -46
  101. data/lib/textus/read/rules.rb +0 -25
@@ -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)
@@ -7,8 +7,8 @@ module Textus
7
7
  REDUCER_TIMEOUT_SECONDS = 2
8
8
 
9
9
  # `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
10
- # semantics: pure read (`ops.get`) for materialization paths;
11
- # `ops.get_or_fetch` if you want fetch-on-stale.
10
+ # semantics: pure read (`Read::Get.new(...).call(key)`, fetch:false default) for
11
+ # materialization paths; `ops.get` (read-through, fetch:true injected) for fetch-on-stale.
12
12
  # `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
13
13
  # `rpc` — a `Hooks::RpcRegistry` used to dispatch `transform_rows` callables.
14
14
  # `transform_context` — capability object handed to transform reducers as `caps:`.
@@ -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
@@ -30,6 +30,25 @@ module Textus
30
30
  end
31
31
  end
32
32
 
33
+ extend Textus::Contract::DSL
34
+
35
+ verb :audit
36
+ summary "Query the audit log with optional filters."
37
+ surfaces :cli
38
+ cli "audit"
39
+ # #call(**filters) — args map to Query.build keyword params (ADR 0063)
40
+ arg :key, String, required: false, description: "filter to rows for this key"
41
+ arg :zone, String, required: false, description: "filter to keys in this zone"
42
+ arg :role, String, required: false, description: "filter to rows written under this role"
43
+ arg :verb, String, required: false, description: "filter to rows for this verb"
44
+ arg :since, String, required: false,
45
+ coerce: ->(s) { Textus::Read::Audit.parse_since(s, now: Time.now) },
46
+ description: "ISO-8601 timestamp or relative offset (e.g. 1h, 30m)"
47
+ arg :seq_since, Integer, required: false, description: "return rows with seq > this cursor value"
48
+ arg :correlation_id, String, required: false, description: "filter to rows with this correlation_id"
49
+ arg :limit, Integer, required: false, description: "maximum number of rows to return"
50
+ view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
51
+
33
52
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
34
53
  @manifest = container.manifest
35
54
  @root = container.root
@@ -7,13 +7,23 @@ module Textus
7
7
  # row. Falls back to `git => nil` when not in a git repo or when the
8
8
  # file is untracked.
9
9
  class Blame
10
+ extend Textus::Contract::DSL
11
+
12
+ verb :blame
13
+ summary "Annotate audit rows for a key with the git commit that introduced each file state."
14
+ surfaces :cli
15
+ cli "blame"
16
+ arg :key, String, required: true, positional: true, description: "entry key to blame"
17
+ arg :limit, Integer, required: false, description: "maximum number of audit rows to return"
18
+ view(:cli) { |rows, inputs| { "verb" => "blame", "key" => inputs[:key], "rows" => rows } }
19
+
10
20
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
11
21
  @container = container
12
22
  @manifest = container.manifest
13
23
  @root = container.root
14
24
  end
15
25
 
16
- def call(key:, limit: nil)
26
+ def call(key, limit: nil)
17
27
  audit_rows = Textus::Read::Audit.new(container: @container).call(key: key, limit: limit)
18
28
  path = resolve_path(key)
19
29
  return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
@@ -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
@@ -1,12 +1,26 @@
1
1
  module Textus
2
2
  module Read
3
3
  class Deps
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :deps
7
+ summary "List the keys a derived entry depends on (its projection/external sources)."
8
+ surfaces :cli, :mcp
9
+ arg :key, String, required: true, positional: true,
10
+ description: "dotted key of the derived entry whose source keys you want"
11
+
4
12
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
13
  @manifest = container.manifest
6
14
  end
7
15
 
8
16
  def call(key)
9
- entry = @manifest.data.entries.find { |e| e.key == key } or return []
17
+ { "key" => key, "deps" => sources_for(key) }
18
+ end
19
+
20
+ private
21
+
22
+ def sources_for(key)
23
+ entry = @manifest.data.entries.find { |e| e.key == key }
10
24
  return [] unless entry.is_a?(Textus::Manifest::Entry::Derived)
11
25
 
12
26
  src = entry.source
@@ -6,6 +6,14 @@ module Textus
6
6
  # The acting role is irrelevant to a read-only health check, so `call`
7
7
  # is not consulted.
8
8
  class Doctor
9
+ extend Textus::Contract::DSL
10
+
11
+ verb :doctor
12
+ summary "Run health checks on the textus store and report any issues."
13
+ surfaces :cli
14
+ cli "doctor"
15
+ arg :checks, Array, required: false, description: "subset of check names to run (default: all)"
16
+
9
17
  def initialize(container:, call:)
10
18
  @container = container
11
19
  @call = call
@@ -7,6 +7,16 @@ module Textus
7
7
  # current status. Status is one of :fresh, :stale, :never_fetched, or
8
8
  # :no_policy.
9
9
  class Freshness
10
+ extend Textus::Contract::DSL
11
+
12
+ verb :freshness
13
+ summary "Report the fetch-freshness status of every entry with a fetch policy."
14
+ surfaces :cli
15
+ cli "freshness"
16
+ arg :prefix, String, required: false, description: "filter to keys with this prefix"
17
+ arg :zone, String, required: false, description: "filter to entries in this zone"
18
+ view(:cli) { |rows| { "verb" => "freshness", "rows" => rows } }
19
+
10
20
  def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
11
21
  @container = container
12
22
  @call = call
@@ -1,54 +1,103 @@
1
1
  module Textus
2
2
  module Read
3
- # Pure read: returns the on-disk envelope annotated with a freshness
4
- # verdict. Never triggers fetch; never invokes the orchestrator.
3
+ # The one read path. `fetch:` controls behavior:
4
+ # fetch: false (default) pure read: the on-disk envelope annotated with
5
+ # a freshness verdict. NEVER builds the orchestrator (no threads/forks/
6
+ # locks/events). This is the safe default for direct (in-process)
7
+ # callers — accept/reject/publish, materializer, uid, validate_all/
8
+ # validator, schema tooling, and the hook context — that must read
9
+ # persisted truth without triggering a fetch.
10
+ # fetch: true — read-through: after a stale verdict, hands off to the
11
+ # fetch orchestrator per the entry's fetch rule (degrades to the pure
12
+ # result when the key has no rule).
5
13
  #
6
- # For interactive reads that want fetch-on-stale, use
7
- # `Read::GetOrFetch`, which composes this with the orchestrator.
14
+ # The public `get` verb is read-through because the contract declares
15
+ # `arg :fetch, default: true`, injected on every verb surface (RoleScope +
16
+ # MCP map_args, ADR 0062 amendment). Direct construction bypasses that
17
+ # injection and so gets the safe `fetch: false` method default.
8
18
  class Get
9
19
  extend Textus::Contract::DSL
10
20
 
11
21
  verb :get
12
- summary "Read one entry. Returns the envelope (uid, etag, _meta, body, freshness)."
13
- surfaces :cli, :ruby, :mcp
22
+ summary "Read one entry. Read-through by default fetches on stale per " \
23
+ "the entry's fetch rule, degrading to a pure read when the key " \
24
+ "has no rule. Pass fetch:false for a guaranteed pure on-disk " \
25
+ "read. Returns the envelope (uid, etag, _meta, body, freshness)."
26
+ surfaces :cli, :mcp
14
27
  arg :key, String, required: true, positional: true,
15
28
  description: "dotted entry key to read, e.g. 'knowledge.project'"
16
- response(&:to_h_for_wire)
29
+ arg :fetch, :boolean, default: true,
30
+ description: "read-through (fetch on stale per the " \
31
+ "entry's fetch rule) when true, the default; " \
32
+ "false returns the on-disk envelope without ever fetching"
33
+ view { |v, _i| v.to_h_for_wire }
17
34
 
18
- def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
35
+ def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator, orchestrator: nil)
19
36
  @container = container
20
37
  @call = call
21
38
  @manifest = container.manifest
22
39
  @file_store = container.file_store
23
40
  @evaluator = evaluator
41
+ @orchestrator = orchestrator # nil → built lazily on first fetch only
24
42
  end
25
43
 
26
- def call(key)
44
+ def call(key, fetch: false)
45
+ envelope = annotated_envelope(key)
46
+ return envelope if envelope.nil?
47
+ return envelope unless fetch && envelope.freshness&.stale
48
+
49
+ fetch_policy = fetch_policy_for(key)
50
+ return envelope if fetch_policy.nil?
51
+
52
+ policy = fetch_policy.to_freshness_policy
53
+ verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
54
+ outcome = orchestrator.execute(policy.decide(verdict), key: key)
55
+ resolve(outcome, envelope)
56
+ end
57
+
58
+ # Strict variant: raises UnknownKey when the entry is missing.
59
+ # Used by consumers (e.g. uid, Validator) that distinguish absence.
60
+ def get(key, fetch: false)
61
+ call(key, fetch: fetch) ||
62
+ raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
63
+ end
64
+
65
+ private
66
+
67
+ # Pure read + freshness verdict; no orchestrator dependency.
68
+ def annotated_envelope(key)
27
69
  envelope = read_raw_envelope(key)
28
70
  return nil if envelope.nil?
29
71
 
30
- policy_set = @manifest.rules.for(key)
31
- fetch_policy = policy_set.fetch
72
+ fetch_policy = fetch_policy_for(key)
32
73
  return annotate_fresh(envelope) if fetch_policy.nil?
33
74
 
34
- policy = fetch_policy.to_freshness_policy
75
+ policy = fetch_policy.to_freshness_policy
35
76
  verdict = @evaluator.call(policy, envelope, now: @call.now)
36
-
37
77
  envelope.with(freshness: Textus::Domain::Freshness.build(
38
- stale: verdict.stale?,
39
- reason: verdict.reason,
40
- fetching: false,
78
+ stale: verdict.stale?, reason: verdict.reason, fetching: false,
41
79
  ))
42
80
  end
43
81
 
44
- # Strict variant: raises UnknownKey when the entry is missing.
45
- # Used by consumers (e.g. Validator) that need to distinguish absence
46
- # from emptiness.
47
- def get(key)
48
- call(key) || raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
82
+ def fetch_policy_for(key)
83
+ @manifest.rules.for(key).fetch
49
84
  end
50
85
 
51
- private
86
+ def resolve(outcome, envelope)
87
+ case outcome
88
+ when Textus::Domain::Outcome::Skipped
89
+ envelope
90
+ when Textus::Domain::Outcome::Fetched
91
+ outcome.envelope.with(
92
+ freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, fetching: false),
93
+ )
94
+ when Textus::Domain::Outcome::Detached
95
+ envelope.with(freshness: envelope.freshness.with(fetching: true))
96
+ when Textus::Domain::Outcome::Failed
97
+ envelope.with(freshness: envelope.freshness.with(fetch_error: outcome.error.message))
98
+ else raise "unexpected fetch outcome: #{outcome.class}"
99
+ end
100
+ end
52
101
 
53
102
  def read_raw_envelope(key)
54
103
  res = @manifest.resolver.resolve(key)
@@ -70,6 +119,22 @@ module Textus
70
119
  stale: false, reason: nil, fetching: false,
71
120
  ))
72
121
  end
122
+
123
+ def orchestrator
124
+ @orchestrator ||= build_orchestrator
125
+ end
126
+
127
+ def build_orchestrator
128
+ worker = Textus::Write::FetchWorker.new(container: @container, call: @call)
129
+ Textus::Write::FetchOrchestrator.new(
130
+ worker: worker, store_root: @container.root, events: @container.events,
131
+ hook_context: hook_context
132
+ )
133
+ end
134
+
135
+ def hook_context
136
+ @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
137
+ end
73
138
  end
74
139
  end
75
140
  end
@@ -5,9 +5,10 @@ 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
+ view(:cli) { |rows| { "entries" => rows } }
11
12
 
12
13
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
13
14
  @manifest = container.manifest
@@ -1,6 +1,13 @@
1
1
  module Textus
2
2
  module Read
3
3
  class Published
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :published
7
+ summary "List all entries that declare a publish_to target."
8
+ surfaces :cli
9
+ cli "published"
10
+
4
11
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
12
  @manifest = container.manifest
6
13
  end
@@ -11,7 +11,8 @@ 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
+ around :cursor
15
16
  arg :since, Integer, session_default: :cursor, description: "audit seq to diff from; defaults to the session cursor"
16
17
 
17
18
  def initialize(container:, call:)
@@ -1,11 +1,25 @@
1
1
  module Textus
2
2
  module Read
3
3
  class Rdeps
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :rdeps
7
+ summary "List the derived entries that depend on a key (reverse deps / impact set)."
8
+ surfaces :cli, :mcp
9
+ arg :key, String, required: true, positional: true,
10
+ description: "dotted key whose dependents (what would be stranded if it moved) you want"
11
+
4
12
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
13
  @manifest = container.manifest
6
14
  end
7
15
 
8
16
  def call(key)
17
+ { "key" => key, "rdeps" => dependents_of(key) }
18
+ end
19
+
20
+ private
21
+
22
+ def dependents_of(key)
9
23
  @manifest.data.entries.each_with_object([]) do |e, acc|
10
24
  next unless e.is_a?(Textus::Manifest::Entry::Derived)
11
25
 
@@ -0,0 +1,84 @@
1
+ module Textus
2
+ module Read
3
+ # Effective rules for a key, at two depths (ADR 0059). Lean by default —
4
+ # `{ fetch, guard }`, the agent-cheap read that was the `rules` verb. With
5
+ # `detail: true` it returns the verbose explanation — every matching policy
6
+ # block plus the per-transition guard predicate names — that was
7
+ # `policy_explain`. One verb, one name across CLI/MCP/method; the audience
8
+ # split is a parameter, not two tools.
9
+ class RuleExplain
10
+ extend Textus::Contract::DSL
11
+
12
+ verb :rule_explain
13
+ summary "Effective rules for a key. Lean {fetch, guard} by default; detail: true adds matched blocks + guard predicates."
14
+ surfaces :cli, :mcp
15
+ cli "rule explain"
16
+ arg :key, String, required: true, positional: true,
17
+ description: "dotted key whose effective rules you want (fetch ttl/action, write guard, ...)"
18
+ arg :detail, :boolean,
19
+ description: "defaults false: lean {fetch, guard}. detail: true adds matched blocks + guard predicates per transition."
20
+ view(:cli) { |r| { "verb" => "rule_explain" }.merge(r.transform_keys(&:to_s)) }
21
+
22
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
23
+ @manifest = container.manifest
24
+ @schemas = container.schemas
25
+ end
26
+
27
+ def call(key, detail: false)
28
+ detail ? explain(key) : effective(key)
29
+ end
30
+
31
+ private
32
+
33
+ # Lean: the effective winners only (formerly Read::Rules / the `rules` verb).
34
+ def effective(key)
35
+ set = @manifest.rules.for(key)
36
+ {
37
+ "fetch" => set.fetch && {
38
+ "ttl_seconds" => set.fetch.ttl_seconds,
39
+ "on_stale" => set.fetch.on_stale,
40
+ "sync_budget_ms" => set.fetch.sync_budget_ms,
41
+ "fetch_timeout_seconds" => set.fetch.fetch_timeout_seconds,
42
+ },
43
+ "guard" => set.guard,
44
+ }.compact
45
+ end
46
+
47
+ # Verbose: every matching block, per-slot effective value, and the
48
+ # effective guard predicate names for each write transition (formerly
49
+ # Read::PolicyExplain, ADR 0031).
50
+ def explain(key)
51
+ matching = @manifest.rules.explain(key)
52
+ winners = @manifest.rules.for(key)
53
+ factory = Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
54
+
55
+ {
56
+ key: key,
57
+ matched_blocks: matching.map do |b|
58
+ {
59
+ match: b.match,
60
+ fetch: !b.fetch.nil?,
61
+ handler_allowlist: !b.handler_allowlist.nil?,
62
+ guard: !b.guard.nil?,
63
+ retention: !b.retention.nil?,
64
+ }
65
+ end,
66
+ effective: {
67
+ fetch: winners.fetch && {
68
+ ttl_seconds: winners.fetch.ttl_seconds,
69
+ on_stale: winners.fetch.on_stale,
70
+ },
71
+ handler_allowlist: winners.handler_allowlist&.handlers,
72
+ retention: winners.retention && {
73
+ expire_after: winners.retention.expire_after,
74
+ archive_after: winners.retention.archive_after,
75
+ },
76
+ },
77
+ guards: Textus::Domain::Policy::BaseGuards::BASE.keys.to_h do |transition|
78
+ [transition, factory.for(transition, key).predicates.map(&:name)]
79
+ end,
80
+ }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,39 @@
1
+ module Textus
2
+ module Read
3
+ # Enumerate every declared rule block in the manifest, in order. This is
4
+ # the whole-manifest view; `rule_explain` is the for-key view. Extracted
5
+ # from the CLI verb so the rule family is fully use-case-backed (ADR 0059);
6
+ # CLI-only (no MCP contract) — an agent reasons per-key via rule_explain.
7
+ class RuleList
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :rule_list
11
+ summary "List every rule block in the manifest."
12
+ surfaces :cli
13
+ cli "rule list"
14
+ view(:cli) { |policies| { "verb" => "rule_list", "policies" => policies } }
15
+
16
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
17
+ @manifest = container.manifest
18
+ end
19
+
20
+ def call
21
+ @manifest.rules.blocks.map do |b|
22
+ row = { "match" => b.match }
23
+ if b.fetch
24
+ row["fetch"] = {
25
+ "ttl_seconds" => b.fetch.ttl_seconds,
26
+ "on_stale" => b.fetch.on_stale,
27
+ "sync_budget_ms" => b.fetch.sync_budget_ms,
28
+ "fetch_timeout_seconds" => b.fetch.fetch_timeout_seconds,
29
+ }
30
+ end
31
+ row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
32
+ row["guard"] = b.guard if b.guard
33
+ row["retention"] = { "expire_after" => b.retention.expire_after, "archive_after" => b.retention.archive_after } if b.retention
34
+ row
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -3,9 +3,10 @@ module Textus
3
3
  class SchemaEnvelope
4
4
  extend Textus::Contract::DSL
5
5
 
6
- verb :schema
6
+ verb :schema_show
7
7
  summary "Return the schema (field shape) for an entry's family, by key."
8
- surfaces :ruby, :mcp
8
+ surfaces :cli, :mcp
9
+ cli "schema show"
9
10
  arg :key, String, required: true, positional: true,
10
11
  description: "any key in the family whose schema you want; returns required/optional fields and their types"
11
12
 
@@ -1,6 +1,15 @@
1
1
  module Textus
2
2
  module Read
3
3
  class Uid
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :uid
7
+ summary "Return the stable UID of an entry without reading its body."
8
+ surfaces :cli
9
+ cli "key uid"
10
+ arg :key, String, required: true, positional: true, description: "entry key"
11
+ view(:cli) { |uid, inputs| { "key" => inputs[:key], "uid" => uid } }
12
+
4
13
  def initialize(container:, call:)
5
14
  @container = container
6
15
  @call = call
@@ -1,6 +1,14 @@
1
1
  module Textus
2
2
  module Read
3
3
  class Where
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :where
7
+ summary "Resolve a key to its zone, owner, and path without reading the body."
8
+ surfaces :cli, :mcp
9
+ arg :key, String, required: true, positional: true,
10
+ description: "dotted key to locate (returns zone, owner, path; does not read content)"
11
+
4
12
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
13
  @manifest = container.manifest
6
14
  end