textus 0.45.1 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +53 -26
  4. data/SPEC.md +15 -14
  5. data/docs/architecture/README.md +6 -25
  6. data/lib/textus/boot.rb +1 -0
  7. data/lib/textus/builder/pipeline.rb +11 -42
  8. data/lib/textus/builder/renderer/markdown.rb +4 -8
  9. data/lib/textus/cli/verb/build.rb +1 -10
  10. data/lib/textus/cli/verb/init.rb +3 -1
  11. data/lib/textus/cli.rb +29 -1
  12. data/lib/textus/container.rb +3 -15
  13. data/lib/textus/contract/resources/build_lock.rb +17 -0
  14. data/lib/textus/dispatcher.rb +1 -0
  15. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  16. data/lib/textus/doctor/check/sentinels.rb +1 -1
  17. data/lib/textus/domain/policy/predicates/fresh_within.rb +6 -5
  18. data/lib/textus/envelope/io/writer.rb +34 -0
  19. data/lib/textus/etag.rb +23 -0
  20. data/lib/textus/hooks/catalog.rb +1 -0
  21. data/lib/textus/init/templates/orientation_reducer.rb +17 -0
  22. data/lib/textus/init.rb +67 -4
  23. data/lib/textus/layout.rb +8 -0
  24. data/lib/textus/maintenance/key_delete_prefix.rb +5 -4
  25. data/lib/textus/maintenance/key_mv_prefix.rb +14 -4
  26. data/lib/textus/maintenance/migrate.rb +5 -4
  27. data/lib/textus/maintenance/rule_lint.rb +1 -1
  28. data/lib/textus/maintenance/zone_mv.rb +5 -4
  29. data/lib/textus/mcp/server.rb +14 -4
  30. data/lib/textus/ports/publisher.rb +3 -2
  31. data/lib/textus/ports/sentinel_store.rb +8 -7
  32. data/lib/textus/projection.rb +4 -3
  33. data/lib/textus/read/audit.rb +1 -1
  34. data/lib/textus/read/blame.rb +1 -1
  35. data/lib/textus/read/boot.rb +1 -1
  36. data/lib/textus/read/capabilities.rb +70 -0
  37. data/lib/textus/read/deps.rb +1 -1
  38. data/lib/textus/read/doctor.rb +1 -1
  39. data/lib/textus/read/freshness.rb +1 -1
  40. data/lib/textus/read/get.rb +1 -1
  41. data/lib/textus/read/list.rb +1 -1
  42. data/lib/textus/read/published.rb +1 -1
  43. data/lib/textus/read/pulse.rb +4 -4
  44. data/lib/textus/read/rdeps.rb +1 -1
  45. data/lib/textus/read/rule_explain.rb +1 -1
  46. data/lib/textus/read/rule_list.rb +1 -1
  47. data/lib/textus/read/schema_envelope.rb +1 -1
  48. data/lib/textus/read/uid.rb +1 -1
  49. data/lib/textus/read/where.rb +1 -1
  50. data/lib/textus/session.rb +6 -5
  51. data/lib/textus/store.rb +48 -25
  52. data/lib/textus/version.rb +1 -1
  53. data/lib/textus/write/accept.rb +1 -1
  54. data/lib/textus/write/build.rb +19 -7
  55. data/lib/textus/write/delete.rb +1 -1
  56. data/lib/textus/write/fetch_all.rb +1 -1
  57. data/lib/textus/write/fetch_worker.rb +1 -1
  58. data/lib/textus/write/mv.rb +1 -1
  59. data/lib/textus/write/propose.rb +1 -1
  60. data/lib/textus/write/put.rb +1 -1
  61. data/lib/textus/write/reject.rb +1 -1
  62. data/lib/textus/write/retention_sweep.rb +1 -1
  63. metadata +4 -1
@@ -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 = File.join(root, Textus::Ports::SentinelStore::DIR)
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 = File.join(root, "sentinels")
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/generated_at) and
39
- # parses the stored ISO-8601 string. Parsing a stored string is not
40
- # I/O (allowed in domain, ADR 0024).
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
@@ -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
- def self.run(target_root)
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"), DEFAULT_MANIFEST)
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, :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
 
@@ -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
- manifest_etag: manifest_etag,
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!(manifest_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 manifest_etag
104
- @store.file_store.etag(File.join(@store.root, "manifest.yaml"))
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>/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,