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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +53 -26
- data/SPEC.md +15 -14
- data/docs/architecture/README.md +6 -25
- 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/verb/build.rb +1 -10
- data/lib/textus/cli/verb/init.rb +3 -1
- data/lib/textus/cli.rb +29 -1
- data/lib/textus/container.rb +3 -15
- data/lib/textus/contract/resources/build_lock.rb +17 -0
- 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/etag.rb +23 -0
- data/lib/textus/hooks/catalog.rb +1 -0
- data/lib/textus/init/templates/orientation_reducer.rb +17 -0
- data/lib/textus/init.rb +67 -4
- 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/mcp/server.rb +14 -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 +4 -4
- 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/session.rb +6 -5
- data/lib/textus/store.rb +48 -25
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +1 -1
- data/lib/textus/write/build.rb +19 -7
- 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 +4 -1
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
# that drift without making `build` scan globally.
|
|
9
9
|
class OrphanedPublishTargets < Check
|
|
10
10
|
def call
|
|
11
|
-
sdir =
|
|
11
|
+
sdir = Textus::Layout.sentinels(root)
|
|
12
12
|
return [] unless File.directory?(sdir)
|
|
13
13
|
|
|
14
14
|
repo_root = File.dirname(root)
|
|
@@ -5,7 +5,7 @@ module Textus
|
|
|
5
5
|
def call
|
|
6
6
|
store = Textus::Ports::SentinelStore.new
|
|
7
7
|
file_stat = Textus::Ports::Storage::FileStat.new
|
|
8
|
-
dir =
|
|
8
|
+
dir = Textus::Layout.sentinels(root)
|
|
9
9
|
return [] unless file_stat.directory?(dir)
|
|
10
10
|
|
|
11
11
|
repo_root = File.dirname(root)
|
|
@@ -35,13 +35,14 @@ module Textus
|
|
|
35
35
|
private
|
|
36
36
|
|
|
37
37
|
# Domain-pure: reads the stored write timestamp from the envelope's
|
|
38
|
-
# freshness (checked_at) or meta (last_fetched_at
|
|
39
|
-
#
|
|
40
|
-
#
|
|
38
|
+
# freshness (checked_at) or meta (last_fetched_at) and parses the
|
|
39
|
+
# stored ISO-8601 string. Parsing a stored string is not I/O (allowed
|
|
40
|
+
# in domain, ADR 0024). `generated_at` is intentionally NOT consulted:
|
|
41
|
+
# build-generation time is no longer carried in the artifact (ADR
|
|
42
|
+
# 0070), and fetch-freshness is a fetch concept, not a build one.
|
|
41
43
|
def written_at(envelope)
|
|
42
44
|
raw = envelope.freshness&.checked_at ||
|
|
43
|
-
envelope.meta&.dig("last_fetched_at")
|
|
44
|
-
envelope.meta&.dig("generated_at")
|
|
45
|
+
envelope.meta&.dig("last_fetched_at")
|
|
45
46
|
return raw if raw.is_a?(Time)
|
|
46
47
|
return nil if raw.nil?
|
|
47
48
|
|
|
@@ -82,6 +82,7 @@ module Textus
|
|
|
82
82
|
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
83
83
|
|
|
84
84
|
@file_store.delete(path)
|
|
85
|
+
prune_empty_parents(path)
|
|
85
86
|
@audit_log.append(
|
|
86
87
|
role: @call.role, verb: "delete", key: key,
|
|
87
88
|
etag_before: etag_before, etag_after: nil,
|
|
@@ -99,6 +100,7 @@ module Textus
|
|
|
99
100
|
|
|
100
101
|
FileUtils.mkdir_p(File.dirname(to_path))
|
|
101
102
|
FileUtils.mv(from_path, to_path)
|
|
103
|
+
prune_empty_parents(from_path)
|
|
102
104
|
basename = to_key.split(".").last
|
|
103
105
|
Entry.for_format(new_mentry.format).rewrite_name(to_path, basename)
|
|
104
106
|
etag_after = Etag.for_file(to_path)
|
|
@@ -129,6 +131,38 @@ module Textus
|
|
|
129
131
|
|
|
130
132
|
private
|
|
131
133
|
|
|
134
|
+
# After a file leaves a directory (delete or move-source), remove any
|
|
135
|
+
# now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
|
|
136
|
+
# (F3 of #161). Floored at the entry's *zone directory* — a zone is a
|
|
137
|
+
# declared, first-class container, so its own dir is preserved even when
|
|
138
|
+
# momentarily empty; only the sub-dirs the bulk op carved out are
|
|
139
|
+
# pruned. Stops at the first non-empty ancestor, so a dir holding a
|
|
140
|
+
# `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
|
|
141
|
+
# non-empty dir is silently fine, never fatal to the write.
|
|
142
|
+
def prune_empty_parents(path)
|
|
143
|
+
floor = zone_floor(path)
|
|
144
|
+
return unless floor
|
|
145
|
+
|
|
146
|
+
dir = File.dirname(path)
|
|
147
|
+
while dir.start_with?("#{floor}/") && Dir.empty?(dir)
|
|
148
|
+
Dir.rmdir(dir)
|
|
149
|
+
dir = File.dirname(dir)
|
|
150
|
+
end
|
|
151
|
+
rescue SystemCallError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The zone directory under which `path` lives (`<root>/zones/<zone>`),
|
|
156
|
+
# or nil if `path` is not under the store's zones tree.
|
|
157
|
+
def zone_floor(path)
|
|
158
|
+
zones_root = File.join(@manifest.data.root, "zones")
|
|
159
|
+
prefix = "#{zones_root}/"
|
|
160
|
+
return nil unless path.start_with?(prefix)
|
|
161
|
+
|
|
162
|
+
zone_seg = path.delete_prefix(prefix).split("/").first
|
|
163
|
+
zone_seg && File.join(zones_root, zone_seg)
|
|
164
|
+
end
|
|
165
|
+
|
|
132
166
|
def ensure_uid(format, meta, content, existing_uid)
|
|
133
167
|
Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
|
|
134
168
|
end
|
data/lib/textus/etag.rb
CHANGED
|
@@ -9,5 +9,28 @@ module Textus
|
|
|
9
9
|
def self.for_file(path)
|
|
10
10
|
for_bytes(File.binread(path))
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
# The fingerprint of everything an agent's boot orientation depends on:
|
|
14
|
+
# the manifest PLUS the executable contract — hooks and schemas. A
|
|
15
|
+
# mid-session edit to any of these makes the cached orientation stale, so
|
|
16
|
+
# the session must re-boot (ADR 0074). The composite is one digest over the
|
|
17
|
+
# sorted per-file listing, so it is order-stable.
|
|
18
|
+
def self.for_contract(root)
|
|
19
|
+
listing = contract_files(root).map do |path|
|
|
20
|
+
rel = path.delete_prefix(root).delete_prefix("/")
|
|
21
|
+
"#{rel}:#{for_file(path)}"
|
|
22
|
+
end.join("\n")
|
|
23
|
+
for_bytes(listing)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# manifest.yaml, then every hook and schema file. Dir.glob already returns
|
|
27
|
+
# sorted paths (Ruby 3.0+), keeping the digest independent of FS order.
|
|
28
|
+
def self.contract_files(root)
|
|
29
|
+
[
|
|
30
|
+
File.join(root, "manifest.yaml"),
|
|
31
|
+
*Dir.glob(File.join(root, "hooks", "**", "*.rb")),
|
|
32
|
+
*Dir.glob(File.join(root, "schemas", "**", "*")).select { |f| File.file?(f) },
|
|
33
|
+
]
|
|
34
|
+
end
|
|
12
35
|
end
|
|
13
36
|
end
|
data/lib/textus/hooks/catalog.rb
CHANGED
|
@@ -20,6 +20,7 @@ module Textus
|
|
|
20
20
|
proposal_rejected: %i[ctx key target_key],
|
|
21
21
|
file_published: %i[ctx key envelope source target],
|
|
22
22
|
store_loaded: %i[ctx],
|
|
23
|
+
session_opened: %i[ctx role cursor],
|
|
23
24
|
fetch_started: %i[ctx key mode],
|
|
24
25
|
fetch_failed: %i[ctx key error_class error_message],
|
|
25
26
|
fetch_backgrounded: %i[ctx key started_at budget_ms],
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Reducer that reshapes the raw projection rows into the keys the
|
|
2
|
+
# orientation.mustache template references. Without this, the template
|
|
3
|
+
# would only have access to the flat rows list.
|
|
4
|
+
Textus.hook do |reg|
|
|
5
|
+
reg.on(:transform_rows, :orientation_reducer) do |rows:, **|
|
|
6
|
+
project_row = rows.find { |r| r["_key"] == "knowledge.project" } || {}
|
|
7
|
+
runbook_rows = rows.select { |r| r["_key"]&.start_with?("knowledge.runbooks.") }
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
"project" => {
|
|
11
|
+
"name" => project_row["name"],
|
|
12
|
+
"description" => project_row["description"]
|
|
13
|
+
},
|
|
14
|
+
"runbooks" => runbook_rows.map { |r| { "name" => r["name"], "description" => r["description"] } }
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -94,13 +94,36 @@ module Textus
|
|
|
94
94
|
Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
|
|
95
95
|
:entry_put, :entry_deleted, :entry_fetched, :entry_renamed,
|
|
96
96
|
:build_completed, :proposal_accepted, :proposal_rejected,
|
|
97
|
-
:file_published, :store_loaded,
|
|
97
|
+
:file_published, :store_loaded, :session_opened,
|
|
98
98
|
:fetch_started, :fetch_failed, :fetch_backgrounded (pub-sub — return discarded)
|
|
99
99
|
|
|
100
100
|
See SPEC.md §5.10 for the full table.
|
|
101
101
|
MD
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
AGENT_ENTRIES = <<~YAML.gsub(/^/, " ")
|
|
104
|
+
# --with-agent profile: project facts + runbooks feed the orientation
|
|
105
|
+
# projection below, which `textus build` renders to CLAUDE.md/AGENTS.md.
|
|
106
|
+
- { key: knowledge.project, path: knowledge/project.md, zone: knowledge, schema: project, owner: human:self, kind: leaf }
|
|
107
|
+
- { key: knowledge.runbooks, path: knowledge/runbooks, zone: knowledge, schema: runbook, owner: human:self, nested: true, kind: nested }
|
|
108
|
+
- key: artifacts.orientation
|
|
109
|
+
path: artifacts/orientation.md
|
|
110
|
+
zone: artifacts
|
|
111
|
+
template: orientation.mustache
|
|
112
|
+
inject_boot: true
|
|
113
|
+
publish:
|
|
114
|
+
to:
|
|
115
|
+
- CLAUDE.md
|
|
116
|
+
- AGENTS.md
|
|
117
|
+
compute:
|
|
118
|
+
kind: projection
|
|
119
|
+
select:
|
|
120
|
+
- knowledge.project
|
|
121
|
+
- knowledge.runbooks
|
|
122
|
+
transform: orientation_reducer
|
|
123
|
+
kind: derived
|
|
124
|
+
YAML
|
|
125
|
+
|
|
126
|
+
def self.run(target_root, with_agent: false)
|
|
104
127
|
raise UsageError.new(".textus/ already exists at #{target_root}") if File.directory?(target_root)
|
|
105
128
|
|
|
106
129
|
FileUtils.mkdir_p(File.join(target_root, "schemas"))
|
|
@@ -115,12 +138,52 @@ module Textus
|
|
|
115
138
|
scaffold_dir = File.expand_path("init/templates", __dir__)
|
|
116
139
|
File.write(File.join(target_root, "hooks", "machine_intake.rb"),
|
|
117
140
|
File.read(File.join(scaffold_dir, "machine_intake.rb")))
|
|
118
|
-
File.write(File.join(target_root, "manifest.yaml"),
|
|
141
|
+
File.write(File.join(target_root, "manifest.yaml"), manifest_yaml(with_agent: with_agent))
|
|
142
|
+
mcp_status = nil
|
|
143
|
+
if with_agent
|
|
144
|
+
scaffold_agent_profile(target_root, scaffold_dir)
|
|
145
|
+
mcp_status = write_mcp_config(target_root, scaffold_dir)
|
|
146
|
+
end
|
|
119
147
|
FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
|
|
120
148
|
FileUtils.mkdir_p(Textus::Layout.state(target_root))
|
|
121
149
|
FileUtils.mkdir_p(Textus::Layout.locks(target_root))
|
|
122
150
|
File.write(File.join(target_root, ".gitignore"), derived_gitignore(target_root))
|
|
123
|
-
{ "protocol" => PROTOCOL, "initialized" => target_root }
|
|
151
|
+
result = { "protocol" => PROTOCOL, "initialized" => target_root, "profile" => with_agent ? "agent" : "default" }
|
|
152
|
+
result["mcp_config"] = mcp_status if with_agent
|
|
153
|
+
result
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Composes the agent profile by inserting AGENT_ENTRIES immediately before the
|
|
157
|
+
# top-level `rules:` block of DEFAULT_MANIFEST — that block is load-bearing for
|
|
158
|
+
# this `.sub`; removing it from DEFAULT_MANIFEST would silently drop the entries.
|
|
159
|
+
def self.manifest_yaml(with_agent:)
|
|
160
|
+
return DEFAULT_MANIFEST unless with_agent
|
|
161
|
+
|
|
162
|
+
DEFAULT_MANIFEST.sub(/^rules:/, "#{AGENT_ENTRIES}rules:")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Copies the proven orientation bundle into a freshly-init'd store.
|
|
166
|
+
def self.scaffold_agent_profile(target_root, scaffold_dir)
|
|
167
|
+
{
|
|
168
|
+
"project.schema.yaml" => File.join("schemas", "project.yaml"),
|
|
169
|
+
"runbook.schema.yaml" => File.join("schemas", "runbook.yaml"),
|
|
170
|
+
"orientation.mustache" => File.join("templates", "orientation.mustache"),
|
|
171
|
+
"orientation_reducer.rb" => File.join("hooks", "orientation_reducer.rb"),
|
|
172
|
+
}.each do |src, dest|
|
|
173
|
+
File.write(File.join(target_root, dest), File.read(File.join(scaffold_dir, src)))
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The one file init writes outside .textus/: a starter .mcp.json at the
|
|
178
|
+
# project root. Write-once — never clobber a hand-authored config. The
|
|
179
|
+
# command form assumes a gem-installed `textus` on PATH; the user owns
|
|
180
|
+
# the file after this first write.
|
|
181
|
+
def self.write_mcp_config(target_root, scaffold_dir)
|
|
182
|
+
dest = File.join(File.dirname(target_root), ".mcp.json")
|
|
183
|
+
return "skipped" if File.exist?(dest)
|
|
184
|
+
|
|
185
|
+
File.write(dest, File.read(File.join(scaffold_dir, "mcp.json")))
|
|
186
|
+
"written"
|
|
124
187
|
end
|
|
125
188
|
|
|
126
189
|
# The store's `.gitignore` is generated, never hand-kept (ADR 0038), and now
|
data/lib/textus/layout.rb
CHANGED
|
@@ -29,6 +29,14 @@ module Textus
|
|
|
29
29
|
File.join(run(root), "audit")
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Sentinels are machine-generated (the published target's sha), not authored
|
|
33
|
+
# source, so they live on the runtime side under `.run/` — git-ignored,
|
|
34
|
+
# regenerated by the next build via content-identical adoption (ADR 0070,
|
|
35
|
+
# superseding ADR 0038's `:config` classification).
|
|
36
|
+
def self.sentinels(root)
|
|
37
|
+
File.join(run(root), "sentinels")
|
|
38
|
+
end
|
|
39
|
+
|
|
32
40
|
def self.audit_log(root)
|
|
33
41
|
File.join(audit_dir(root), "audit.log")
|
|
34
42
|
end
|
|
@@ -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
|
|
data/lib/textus/mcp/server.rb
CHANGED
|
@@ -59,7 +59,17 @@ module Textus
|
|
|
59
59
|
role: @role,
|
|
60
60
|
cursor: @store.audit_log.latest_seq,
|
|
61
61
|
propose_zone: propose_zone,
|
|
62
|
-
|
|
62
|
+
contract_etag: contract_etag,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# ADR 0075: announce the connection to connect-time hooks with the
|
|
66
|
+
# resolved role. Distinct from :store_loaded (fired at Store.new under
|
|
67
|
+
# the default role, before any connection's role is known).
|
|
68
|
+
@store.events.publish(
|
|
69
|
+
:session_opened,
|
|
70
|
+
ctx: Hooks::Context.new(scope: @store.as(@role)),
|
|
71
|
+
role: @role,
|
|
72
|
+
cursor: @session.cursor,
|
|
63
73
|
)
|
|
64
74
|
|
|
65
75
|
emit_result(rid, {
|
|
@@ -79,7 +89,7 @@ module Textus
|
|
|
79
89
|
return
|
|
80
90
|
end
|
|
81
91
|
|
|
82
|
-
@session.check_etag!(
|
|
92
|
+
@session.check_etag!(contract_etag)
|
|
83
93
|
|
|
84
94
|
name = params["name"]
|
|
85
95
|
args = params["arguments"] || {}
|
|
@@ -100,8 +110,8 @@ module Textus
|
|
|
100
110
|
emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
|
|
101
111
|
end
|
|
102
112
|
|
|
103
|
-
def
|
|
104
|
-
|
|
113
|
+
def contract_etag
|
|
114
|
+
Textus::Etag.for_contract(@store.root)
|
|
105
115
|
end
|
|
106
116
|
|
|
107
117
|
def emit_result(rid, result)
|
|
@@ -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,
|