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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +53 -26
- data/SPEC.md +11 -11
- data/docs/architecture/README.md +4 -23
- data/lib/textus/boot.rb +1 -0
- data/lib/textus/builder/pipeline.rb +11 -42
- data/lib/textus/builder/renderer/markdown.rb +4 -8
- data/lib/textus/cli.rb +29 -1
- data/lib/textus/container.rb +3 -15
- data/lib/textus/dispatcher.rb +1 -0
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +1 -1
- data/lib/textus/domain/policy/predicates/fresh_within.rb +6 -5
- data/lib/textus/envelope/io/writer.rb +34 -0
- data/lib/textus/layout.rb +8 -0
- data/lib/textus/maintenance/key_delete_prefix.rb +5 -4
- data/lib/textus/maintenance/key_mv_prefix.rb +14 -4
- data/lib/textus/maintenance/migrate.rb +5 -4
- data/lib/textus/maintenance/rule_lint.rb +1 -1
- data/lib/textus/maintenance/zone_mv.rb +5 -4
- data/lib/textus/ports/publisher.rb +3 -2
- data/lib/textus/ports/sentinel_store.rb +8 -7
- data/lib/textus/projection.rb +4 -3
- data/lib/textus/read/audit.rb +1 -1
- data/lib/textus/read/blame.rb +1 -1
- data/lib/textus/read/boot.rb +1 -1
- data/lib/textus/read/capabilities.rb +70 -0
- data/lib/textus/read/deps.rb +1 -1
- data/lib/textus/read/doctor.rb +1 -1
- data/lib/textus/read/freshness.rb +1 -1
- data/lib/textus/read/get.rb +1 -1
- data/lib/textus/read/list.rb +1 -1
- data/lib/textus/read/published.rb +1 -1
- data/lib/textus/read/pulse.rb +1 -1
- data/lib/textus/read/rdeps.rb +1 -1
- data/lib/textus/read/rule_explain.rb +1 -1
- data/lib/textus/read/rule_list.rb +1 -1
- data/lib/textus/read/schema_envelope.rb +1 -1
- data/lib/textus/read/uid.rb +1 -1
- data/lib/textus/read/where.rb +1 -1
- data/lib/textus/store.rb +47 -24
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +1 -1
- data/lib/textus/write/build.rb +1 -1
- data/lib/textus/write/delete.rb +1 -1
- data/lib/textus/write/fetch_all.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +1 -1
- data/lib/textus/write/mv.rb +1 -1
- data/lib/textus/write/propose.rb +1 -1
- data/lib/textus/write/put.rb +1 -1
- data/lib/textus/write/reject.rb +1 -1
- data/lib/textus/write/retention_sweep.rb +1 -1
- 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, :
|
|
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:
|
|
13
|
-
description: "
|
|
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:
|
|
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, :
|
|
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:
|
|
15
|
-
description: "
|
|
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:
|
|
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, :
|
|
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:
|
|
16
|
-
description: "
|
|
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:
|
|
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, :
|
|
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, :
|
|
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:
|
|
18
|
-
description: "
|
|
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:
|
|
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
|
|
11
|
-
# so consumer directories aren't
|
|
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
|
|
9
|
-
# and all File/FileUtils I/O.
|
|
10
|
-
# depends on this port for
|
|
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,
|
|
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
|
-
|
|
49
|
+
root = Textus::Layout.sentinels(store_root)
|
|
50
|
+
sdir = File.join(root, rel)
|
|
50
51
|
return [] unless File.directory?(sdir)
|
|
51
52
|
|
|
52
|
-
prefix =
|
|
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)
|
data/lib/textus/projection.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
data/lib/textus/read/audit.rb
CHANGED
|
@@ -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
|
|
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"
|
data/lib/textus/read/blame.rb
CHANGED
|
@@ -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
|
|
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"
|
data/lib/textus/read/boot.rb
CHANGED
|
@@ -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
|
data/lib/textus/read/deps.rb
CHANGED
|
@@ -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, :
|
|
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
|
|
data/lib/textus/read/doctor.rb
CHANGED
|
@@ -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
|
|
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
|
|
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"
|
data/lib/textus/read/get.rb
CHANGED
|
@@ -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, :
|
|
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,
|
data/lib/textus/read/list.rb
CHANGED
|
@@ -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, :
|
|
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 } }
|
data/lib/textus/read/pulse.rb
CHANGED
|
@@ -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, :
|
|
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
|
|
data/lib/textus/read/rdeps.rb
CHANGED
|
@@ -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, :
|
|
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, :
|
|
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, ...)"
|
|
@@ -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, :
|
|
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"
|
data/lib/textus/read/uid.rb
CHANGED
|
@@ -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
|
|
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 } }
|
data/lib/textus/read/where.rb
CHANGED
|
@@ -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, :
|
|
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 :
|
|
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
|
-
|
|
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
|
|
25
|
+
return new(candidate) if store_dir?(candidate)
|
|
15
26
|
|
|
16
27
|
parent = File.dirname(dir)
|
|
17
|
-
|
|
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
|
|
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
|
|
32
|
-
|
|
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
|
|
50
|
-
@container
|
|
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
|
data/lib/textus/version.rb
CHANGED
data/lib/textus/write/accept.rb
CHANGED
|
@@ -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, :
|
|
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
|
|
data/lib/textus/write/build.rb
CHANGED
|
@@ -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
|
|
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
|
|
data/lib/textus/write/delete.rb
CHANGED
|
@@ -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, :
|
|
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, :
|
|
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, :
|
|
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 } }
|
data/lib/textus/write/mv.rb
CHANGED
|
@@ -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, :
|
|
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"
|
data/lib/textus/write/propose.rb
CHANGED
|
@@ -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, :
|
|
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'"
|
data/lib/textus/write/put.rb
CHANGED
|
@@ -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, :
|
|
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,
|
data/lib/textus/write/reject.rb
CHANGED