textus 0.12.1 → 0.14.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +60 -40
  3. data/CHANGELOG.md +214 -0
  4. data/README.md +6 -12
  5. data/SPEC.md +4 -1
  6. data/docs/conventions.md +8 -8
  7. data/lib/textus/application/context.rb +4 -0
  8. data/lib/textus/application/reads/blame.rb +1 -1
  9. data/lib/textus/application/reads/deps.rb +15 -0
  10. data/lib/textus/application/reads/freshness.rb +2 -2
  11. data/lib/textus/application/reads/get.rb +8 -11
  12. data/lib/textus/application/reads/list.rb +15 -0
  13. data/lib/textus/application/reads/published.rb +15 -0
  14. data/lib/textus/application/reads/rdeps.rb +15 -0
  15. data/lib/textus/application/reads/schema_envelope.rb +15 -0
  16. data/lib/textus/application/reads/stale.rb +15 -0
  17. data/lib/textus/application/reads/uid.rb +15 -0
  18. data/lib/textus/application/reads/validate_all.rb +15 -0
  19. data/lib/textus/application/reads/where.rb +15 -0
  20. data/lib/textus/application/refresh/all.rb +2 -2
  21. data/lib/textus/application/refresh/worker.rb +3 -3
  22. data/lib/textus/application/writes/accept.rb +7 -7
  23. data/lib/textus/application/writes/build.rb +10 -47
  24. data/lib/textus/application/writes/mv.rb +144 -0
  25. data/lib/textus/application/writes/publish.rb +41 -9
  26. data/lib/textus/application/writes/reject.rb +37 -0
  27. data/lib/textus/cli/verb/accept.rb +1 -2
  28. data/lib/textus/cli/verb/audit.rb +3 -3
  29. data/lib/textus/cli/verb/blame.rb +1 -2
  30. data/lib/textus/cli/verb/build.rb +6 -2
  31. data/lib/textus/cli/verb/delete.rb +1 -2
  32. data/lib/textus/cli/verb/deps.rb +1 -1
  33. data/lib/textus/cli/verb/freshness.rb +1 -2
  34. data/lib/textus/cli/verb/get.rb +2 -3
  35. data/lib/textus/cli/verb/list.rb +1 -1
  36. data/lib/textus/cli/verb/mv.rb +1 -1
  37. data/lib/textus/cli/verb/published.rb +1 -1
  38. data/lib/textus/cli/verb/put.rb +2 -2
  39. data/lib/textus/cli/verb/rdeps.rb +1 -1
  40. data/lib/textus/cli/verb/refresh.rb +1 -2
  41. data/lib/textus/cli/verb/reject.rb +1 -1
  42. data/lib/textus/cli/verb/rule_explain.rb +1 -2
  43. data/lib/textus/cli/verb/schema.rb +1 -1
  44. data/lib/textus/cli/verb/uid.rb +1 -1
  45. data/lib/textus/cli/verb/where.rb +1 -1
  46. data/lib/textus/cli/verb.rb +6 -1
  47. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  48. data/lib/textus/doctor.rb +1 -1
  49. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  50. data/lib/textus/domain/policy/predicates/schema_valid.rb +3 -3
  51. data/lib/textus/entry/base.rb +28 -0
  52. data/lib/textus/entry/json.rb +59 -0
  53. data/lib/textus/entry/markdown.rb +46 -0
  54. data/lib/textus/entry/text.rb +35 -0
  55. data/lib/textus/entry/yaml.rb +59 -0
  56. data/lib/textus/entry.rb +16 -0
  57. data/lib/textus/envelope.rb +44 -14
  58. data/lib/textus/intro.rb +56 -0
  59. data/lib/textus/manifest/entry/parser.rb +84 -0
  60. data/lib/textus/manifest/entry/validators/events.rb +21 -0
  61. data/lib/textus/manifest/entry/validators/format_matrix.rb +26 -0
  62. data/lib/textus/manifest/entry/validators/index_filename.rb +45 -0
  63. data/lib/textus/manifest/entry/validators/inject_intro.rb +18 -0
  64. data/lib/textus/manifest/entry/validators/publish_each.rb +37 -0
  65. data/lib/textus/manifest/entry/validators.rb +20 -0
  66. data/lib/textus/manifest/entry.rb +35 -213
  67. data/lib/textus/manifest.rb +6 -16
  68. data/lib/textus/operations/reads.rb +39 -0
  69. data/lib/textus/operations/refresh.rb +27 -0
  70. data/lib/textus/operations/writes.rb +21 -0
  71. data/lib/textus/operations.rb +44 -0
  72. data/lib/textus/projection.rb +5 -4
  73. data/lib/textus/refresh.rb +3 -4
  74. data/lib/textus/schema/tools.rb +8 -7
  75. data/lib/textus/store/reader.rb +1 -1
  76. data/lib/textus/store/validator.rb +3 -3
  77. data/lib/textus/store/writer.rb +5 -74
  78. data/lib/textus/store.rb +1 -55
  79. data/lib/textus/version.rb +1 -1
  80. metadata +23 -4
  81. data/lib/textus/composition.rb +0 -72
  82. data/lib/textus/proposal.rb +0 -10
  83. data/lib/textus/store/mover.rb +0 -167
@@ -4,7 +4,7 @@ module Textus
4
4
  class Uid < Verb
5
5
  def call(store)
6
6
  key = positional.shift or raise UsageError.new("uid requires a key")
7
- emit({ "key" => key, "uid" => store.uid(key) })
7
+ emit({ "key" => key, "uid" => operations_for(store).reads.uid.call(key) })
8
8
  end
9
9
  end
10
10
  end
@@ -4,7 +4,7 @@ module Textus
4
4
  class Where < Verb
5
5
  def call(store)
6
6
  key = positional.shift or raise UsageError.new("where requires a key")
7
- emit(store.where(key))
7
+ emit(operations_for(store).reads.where.call(key))
8
8
  end
9
9
  end
10
10
  end
@@ -70,7 +70,12 @@ module Textus
70
70
  # Convenience for verbs whose only pre-call boilerplate is
71
71
  # resolving the role and wrapping it in a context.
72
72
  def context_for(store)
73
- Textus::Composition.context(store, role: resolved_role(store))
73
+ Textus::Operations.for(store, role: resolved_role(store)).ctx
74
+ end
75
+
76
+ # Returns an Operations instance bound to the resolved role.
77
+ def operations_for(store)
78
+ Textus::Operations.for(store, role: resolved_role(store))
74
79
  end
75
80
  end
76
81
  end
@@ -3,7 +3,7 @@ module Textus
3
3
  class Check
4
4
  class SchemaViolations < Check
5
5
  def call
6
- res = store.validate_all
6
+ res = Textus::Operations.for(store).reads.validate_all.call
7
7
  res["violations"].map do |v|
8
8
  fix = v["expected"] &&
9
9
  "field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
data/lib/textus/doctor.rb CHANGED
@@ -53,7 +53,7 @@ module Textus
53
53
 
54
54
  def run_registered_checks(store)
55
55
  out = []
56
- view = Application::Context.new(store: store, role: "human")
56
+ view = Application::Context.system(store)
57
57
  store.registry.rpc_names(:validate).each do |name|
58
58
  callable = store.registry.rpc_callable(:validate, name)
59
59
  begin
@@ -9,7 +9,7 @@ module Textus
9
9
  def call(policy, envelope, now:)
10
10
  return Verdict.fresh if policy.ttl_seconds.nil?
11
11
 
12
- last_str = envelope.dig("_meta", "last_refreshed_at")
12
+ last_str = envelope&.meta&.dig("last_refreshed_at")
13
13
  return Verdict.stale("never refreshed") if last_str.nil?
14
14
 
15
15
  last = begin
@@ -9,10 +9,10 @@ module Textus
9
9
  "schema_valid"
10
10
  end
11
11
 
12
- def call(entry:, store:)
12
+ def call(entry:, store:) # rubocop:disable Metrics/PerceivedComplexity
13
13
  return true if entry.nil? || store.nil?
14
14
 
15
- target_key = entry.dig("_meta", "proposal", "target_key")
15
+ target_key = entry.meta&.dig("proposal", "target_key")
16
16
  return true unless target_key
17
17
 
18
18
  mentry, = store.manifest.resolve(target_key)
@@ -22,7 +22,7 @@ module Textus
22
22
  schema = store.schema_for(schema_ref)
23
23
  return true unless schema
24
24
 
25
- frontmatter = entry.dig("_meta", "frontmatter") || {}
25
+ frontmatter = entry.meta&.dig("frontmatter") || {}
26
26
  begin
27
27
  schema.validate!(frontmatter)
28
28
  rescue Textus::SchemaViolation => e
@@ -25,6 +25,34 @@ module Textus
25
25
  def self.validate_against(schema, parsed)
26
26
  schema.validate!(parsed["_meta"] || {})
27
27
  end
28
+
29
+ def self.nested_glob
30
+ raise NotImplementedError.new("#{name}.nested_glob not implemented")
31
+ end
32
+
33
+ def self.validate_path_extension(_path, _nested)
34
+ raise NotImplementedError.new("#{name}.validate_path_extension not implemented")
35
+ end
36
+
37
+ def self.inject_uid(_meta, _content, _existing_uid)
38
+ raise NotImplementedError.new("#{name}.inject_uid not implemented")
39
+ end
40
+
41
+ def self.enforce_name_match!(_path, _meta)
42
+ raise NotImplementedError.new("#{name}.enforce_name_match! not implemented")
43
+ end
44
+
45
+ def self.serialize_for_put(meta:, body:, content:, path:)
46
+ _ = meta
47
+ _ = body
48
+ _ = content
49
+ _ = path
50
+ raise NotImplementedError.new("#{name}.serialize_for_put not implemented")
51
+ end
52
+
53
+ def self.rewrite_name(_path, _basename)
54
+ raise NotImplementedError.new("#{name}.rewrite_name not implemented")
55
+ end
28
56
  end
29
57
  end
30
58
  end
@@ -42,6 +42,65 @@ module Textus
42
42
  end
43
43
 
44
44
  def self.extensions = [".json"]
45
+
46
+ def self.nested_glob = "**/*.json"
47
+
48
+ def self.serialize_for_put(meta:, body:, content:, path:)
49
+ raise UsageError.new("put for json requires content: or body:") if content.nil? && (body.nil? || body.to_s.empty?)
50
+
51
+ if content.nil?
52
+ begin
53
+ parsed = parse(body.to_s, path: path)
54
+ rescue BadFrontmatter => e
55
+ raise BadContent.new(path, "bad_content: #{e.message}")
56
+ end
57
+ [body.to_s, parsed["_meta"], body.to_s, parsed["content"]]
58
+ else
59
+ bytes = serialize(meta: meta, body: "", content: content)
60
+ [bytes, meta, bytes, content]
61
+ end
62
+ end
63
+
64
+ # Mutating filesystem op; returns true if a write happened.
65
+ def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
66
+ raw = File.binread(path)
67
+ parsed = parse(raw, path: path)
68
+ meta = parsed["_meta"]
69
+ return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
70
+
71
+ new_meta = meta.merge("name" => basename)
72
+ File.binwrite(path, serialize(meta: new_meta, body: "", content: parsed["content"]))
73
+ true
74
+ end
75
+
76
+ def self.enforce_name_match!(path, meta)
77
+ return unless meta.is_a?(Hash) && meta["name"]
78
+
79
+ ext = extensions.first
80
+ basename = File.basename(path, ext)
81
+ return if meta["name"] == basename
82
+
83
+ raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
84
+ end
85
+
86
+ def self.inject_uid(meta, content, existing_uid)
87
+ m = meta.is_a?(Hash) ? meta.dup : {}
88
+ m["uid"] = existing_uid || Textus::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
89
+ [m, content]
90
+ end
91
+
92
+ def self.validate_path_extension(path, nested)
93
+ ext = File.extname(path)
94
+ if nested
95
+ return if ext == ""
96
+
97
+ raise UsageError.new("nested json path must not have an extension")
98
+ end
99
+
100
+ return if ext == ".json"
101
+
102
+ raise UsageError.new("json format requires '.json' path (got #{ext.inspect})")
103
+ end
45
104
  end
46
105
  end
47
106
  end
@@ -34,6 +34,52 @@ module Textus
34
34
  end
35
35
 
36
36
  def self.extensions = [".md"]
37
+
38
+ def self.nested_glob = "**/*.md"
39
+
40
+ def self.serialize_for_put(meta:, body:, content:, path:)
41
+ _ = path
42
+ _ = content
43
+ bytes = serialize(meta: meta || {}, body: body.to_s)
44
+ [bytes, meta, body.to_s, nil]
45
+ end
46
+
47
+ # Mutating filesystem op; returns true if a write happened (boolean is
48
+ # informational, not a predicate). Rubocop's predicate-name heuristic
49
+ # disabled here on purpose.
50
+ def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
51
+ raw = File.binread(path)
52
+ parsed = parse(raw, path: path)
53
+ meta = parsed["_meta"] || {}
54
+ return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
55
+
56
+ new_meta = meta.merge("name" => basename)
57
+ File.binwrite(path, serialize(meta: new_meta, body: parsed["body"]))
58
+ true
59
+ end
60
+
61
+ def self.enforce_name_match!(path, meta)
62
+ return unless meta.is_a?(Hash) && meta["name"]
63
+
64
+ ext = extensions.first
65
+ basename = File.basename(path, ext)
66
+ return if meta["name"] == basename
67
+
68
+ raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
69
+ end
70
+
71
+ def self.inject_uid(meta, content, existing_uid)
72
+ m = meta.is_a?(Hash) ? meta.dup : {}
73
+ m["uid"] = existing_uid || Textus::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
74
+ [m, content]
75
+ end
76
+
77
+ def self.validate_path_extension(path, _nested)
78
+ ext = File.extname(path)
79
+ return if ["", ".md"].include?(ext)
80
+
81
+ raise UsageError.new("markdown format requires '.md' path (got #{ext.inspect})")
82
+ end
37
83
  end
38
84
  end
39
85
  end
@@ -18,6 +18,41 @@ module Textus
18
18
  end
19
19
 
20
20
  def self.extensions = [".txt"]
21
+
22
+ def self.nested_glob = "**/*.txt"
23
+
24
+ def self.inject_uid(meta, content, _existing_uid)
25
+ [meta, content]
26
+ end
27
+
28
+ def self.enforce_name_match!(_path, _meta)
29
+ # text has no meta home; no-op
30
+ end
31
+
32
+ def self.serialize_for_put(meta:, body:, content:, path:)
33
+ _ = path
34
+ _ = content
35
+ bytes = serialize(meta: meta || {}, body: body.to_s)
36
+ [bytes, meta, body.to_s, nil]
37
+ end
38
+
39
+ # No-op; text has no meta. Returns false (never writes).
40
+ def self.rewrite_name(_path, _basename) # rubocop:disable Naming/PredicateMethod
41
+ false
42
+ end
43
+
44
+ def self.validate_path_extension(path, nested)
45
+ ext = File.extname(path)
46
+ if nested
47
+ return if ext == ""
48
+
49
+ raise UsageError.new("nested text path must not have an extension")
50
+ end
51
+
52
+ return if [".txt", ""].include?(ext)
53
+
54
+ raise UsageError.new("text format requires '.txt' or no extension (got #{ext.inspect})")
55
+ end
21
56
  end
22
57
  end
23
58
  end
@@ -40,6 +40,65 @@ module Textus
40
40
  end
41
41
 
42
42
  def self.extensions = [".yaml", ".yml"]
43
+
44
+ def self.nested_glob = "**/*.{yaml,yml}"
45
+
46
+ def self.serialize_for_put(meta:, body:, content:, path:)
47
+ raise UsageError.new("put for yaml requires content: or body:") if content.nil? && (body.nil? || body.to_s.empty?)
48
+
49
+ if content.nil?
50
+ begin
51
+ parsed = parse(body.to_s, path: path)
52
+ rescue BadFrontmatter => e
53
+ raise BadContent.new(path, "bad_content: #{e.message}")
54
+ end
55
+ [body.to_s, parsed["_meta"], body.to_s, parsed["content"]]
56
+ else
57
+ bytes = serialize(meta: meta, body: "", content: content)
58
+ [bytes, meta, bytes, content]
59
+ end
60
+ end
61
+
62
+ # Mutating filesystem op; returns true if a write happened.
63
+ def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
64
+ raw = File.binread(path)
65
+ parsed = parse(raw, path: path)
66
+ meta = parsed["_meta"]
67
+ return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
68
+
69
+ new_meta = meta.merge("name" => basename)
70
+ File.binwrite(path, serialize(meta: new_meta, body: "", content: parsed["content"]))
71
+ true
72
+ end
73
+
74
+ def self.enforce_name_match!(path, meta)
75
+ return unless meta.is_a?(Hash) && meta["name"]
76
+
77
+ ext = extensions.first
78
+ basename = File.basename(path, ext)
79
+ return if meta["name"] == basename
80
+
81
+ raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
82
+ end
83
+
84
+ def self.inject_uid(meta, content, existing_uid)
85
+ m = meta.is_a?(Hash) ? meta.dup : {}
86
+ m["uid"] = existing_uid || Textus::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
87
+ [m, content]
88
+ end
89
+
90
+ def self.validate_path_extension(path, nested)
91
+ ext = File.extname(path)
92
+ if nested
93
+ return if ext == ""
94
+
95
+ raise UsageError.new("nested yaml path must not have an extension")
96
+ end
97
+
98
+ return if [".yaml", ".yml"].include?(ext)
99
+
100
+ raise UsageError.new("yaml format requires '.yaml' or '.yml' path (got #{ext.inspect})")
101
+ end
43
102
  end
44
103
  end
45
104
  end
data/lib/textus/entry.rb CHANGED
@@ -10,10 +10,26 @@ module Textus
10
10
  "text" => Text,
11
11
  }.freeze
12
12
 
13
+ EXT_TO_FORMAT = {
14
+ ".md" => "markdown",
15
+ ".json" => "json",
16
+ ".yaml" => "yaml",
17
+ ".yml" => "yaml",
18
+ ".txt" => "text",
19
+ }.freeze
20
+
13
21
  def self.for_format(format)
14
22
  STRATEGIES.fetch(format.to_s) { raise UsageError.new("unknown entry format: #{format.inspect}") }
15
23
  end
16
24
 
25
+ def self.infer_from_extension(ext)
26
+ EXT_TO_FORMAT[ext]
27
+ end
28
+
29
+ def self.formats
30
+ EXT_TO_FORMAT.values.uniq
31
+ end
32
+
17
33
  def self.parse(raw, path: nil, format: "markdown")
18
34
  for_format(format).parse(raw, path: path)
19
35
  end
@@ -1,30 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Envelope
4
+ Envelope = Data.define(
5
+ :protocol, :key, :zone, :owner, :path, :format,
6
+ :uid, :etag, :schema_ref, :meta, :body, :content, :freshness
7
+ ) do
5
8
  # rubocop:disable Metrics/ParameterLists
6
- def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil)
9
+ def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
7
10
  # rubocop:enable Metrics/ParameterLists
8
- env = {
9
- "protocol" => PROTOCOL,
11
+ new(
12
+ protocol: Textus::PROTOCOL,
13
+ key: key,
14
+ zone: mentry.zone,
15
+ owner: mentry.owner,
16
+ path: path,
17
+ format: mentry.format,
18
+ uid: extract_uid(meta),
19
+ etag: etag,
20
+ schema_ref: mentry.schema,
21
+ meta: meta,
22
+ body: body,
23
+ content: content,
24
+ freshness: freshness,
25
+ )
26
+ end
27
+
28
+ def self.extract_uid(meta)
29
+ v = meta.is_a?(Hash) ? meta["uid"] : nil
30
+ v.is_a?(String) ? v : nil
31
+ end
32
+
33
+ def to_h_for_wire
34
+ h = {
35
+ "protocol" => protocol,
10
36
  "key" => key,
11
- "zone" => mentry.zone,
12
- "owner" => mentry.owner,
37
+ "zone" => zone,
38
+ "owner" => owner,
13
39
  "path" => path,
14
- "format" => mentry.format,
40
+ "format" => format,
15
41
  "_meta" => meta,
16
42
  "body" => body,
17
43
  "etag" => etag,
18
- "schema_ref" => mentry.schema,
19
- "uid" => extract_uid(meta),
44
+ "schema_ref" => schema_ref,
45
+ "uid" => uid,
20
46
  }
21
- env["content"] = content unless content.nil?
22
- env
47
+ h["content"] = content unless content.nil?
48
+ freshness.each { |k, v| h[k.to_s] = v } if freshness.is_a?(Hash)
49
+ h
23
50
  end
24
51
 
25
- def self.extract_uid(meta)
26
- v = meta.is_a?(Hash) ? meta["uid"] : nil
27
- v.is_a?(String) ? v : nil
52
+ def stale?
53
+ freshness.is_a?(Hash) && (freshness["stale"] == true || freshness[:stale] == true)
54
+ end
55
+
56
+ def refreshing?
57
+ freshness.is_a?(Hash) && (freshness["refreshing"] == true || freshness[:refreshing] == true)
28
58
  end
29
59
  end
30
60
  end
data/lib/textus/intro.rb CHANGED
@@ -26,6 +26,61 @@ module Textus
26
26
  "builder" => "'textus build' computes output entries from projections; output files are never hand-edited",
27
27
  }.freeze
28
28
 
29
+ # Static, store-independent guide to the agent-facing protocol. Surfaced
30
+ # under the new top-level `agent_protocol` key in Intro.run. Recipes
31
+ # describe CLI verbs (not Ruby Operations) because the audience is an
32
+ # agent driving textus from the command line.
33
+ AGENT_PROTOCOL = {
34
+ "envelope_shape" => {
35
+ "summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
36
+ "fields" => {
37
+ "_meta" => "hash of structured frontmatter; schema-validated per entry family",
38
+ "body" => "string payload (markdown/text) or nil for json/yaml formats where body lives in _meta",
39
+ "uid" => "stable 16-char hex identifier; preserved across writes and key renames",
40
+ "etag" => "content hash; pass back on writes to detect concurrent edits",
41
+ },
42
+ "ref" => "SPEC.md §8",
43
+ },
44
+ "role_resolution" => {
45
+ "summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, default human",
46
+ "roles" => %w[human agent runner builder],
47
+ "ref" => "SPEC.md §5",
48
+ },
49
+ "recipes" => {
50
+ "read" => {
51
+ "purpose" => "find and read an entry",
52
+ "steps" => [
53
+ "textus list --zone=ZONE --prefix=PREFIX # discover keys",
54
+ "textus get KEY # returns envelope JSON",
55
+ ],
56
+ },
57
+ "write" => {
58
+ "purpose" => "create or update an entry",
59
+ "steps" => [
60
+ "textus schema get FAMILY # learn the _meta field shape",
61
+ "build an envelope JSON: {_meta: {...}, body: \"...\"}",
62
+ "echo ENVELOPE | textus put KEY --as=ROLE --stdin",
63
+ ],
64
+ },
65
+ "propose" => {
66
+ "purpose" => "agent suggests a change for human review",
67
+ "agent_steps" => [
68
+ "echo ENVELOPE | textus put review.KEY --as=agent --stdin",
69
+ ],
70
+ "human_steps" => [
71
+ "textus accept review.KEY --as=human # promotes the proposal to its target zone",
72
+ ],
73
+ },
74
+ "refresh" => {
75
+ "purpose" => "rebuild stale intake-zone caches from their declared actions",
76
+ "steps" => [
77
+ "textus freshness --zone=intake # report fresh/stale per entry",
78
+ "textus refresh stale --zone=intake --as=runner",
79
+ ],
80
+ },
81
+ },
82
+ }.freeze
83
+
29
84
  # The CLI verb catalog. Truth lives here; do not derive dynamically.
30
85
  # Agents that read intro should see a stable shape regardless of how
31
86
  # verb implementations evolve.
@@ -59,6 +114,7 @@ module Textus
59
114
  "hooks" => hooks_for(store),
60
115
  "write_flows" => WRITE_FLOWS.dup,
61
116
  "cli_verbs" => CLI_VERBS.map(&:dup),
117
+ "agent_protocol" => AGENT_PROTOCOL,
62
118
  "docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
63
119
  }
64
120
  end
@@ -0,0 +1,84 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Parser
5
+ COMPUTE_KINDS = %w[projection external].freeze
6
+
7
+ def self.call(manifest, raw)
8
+ key = raw["key"] or raise UsageError.new("manifest entry missing key")
9
+ path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
10
+ zone = raw["zone"] or raise UsageError.new("manifest entry '#{key}' missing zone")
11
+
12
+ nested = raw["nested"] == true
13
+ compute, projection, generator = parse_compute(raw, key)
14
+ intake_handler, intake_config = parse_intake(raw["intake"])
15
+ format = resolve_format(raw, path, nested)
16
+
17
+ Textus::Manifest::Entry.new(
18
+ manifest: manifest, raw: raw,
19
+ key: key, path: path, zone: zone,
20
+ schema: raw["schema"], owner: raw["owner"],
21
+ nested: nested,
22
+ template: raw["template"],
23
+ publish_to: Array(raw["publish_to"]),
24
+ publish_each: raw["publish_each"],
25
+ events: raw["events"] || {},
26
+ inject_intro: raw["inject_intro"] == true,
27
+ index_filename: raw["index_filename"],
28
+ format: format,
29
+ compute: compute, projection: projection, generator: generator,
30
+ intake_handler: intake_handler, intake_config: intake_config
31
+ )
32
+ end
33
+
34
+ def self.parse_compute(raw, key)
35
+ src = raw["compute"]
36
+ return [nil, nil, nil] if src.nil?
37
+
38
+ kind = src["kind"]
39
+ unless COMPUTE_KINDS.include?(kind)
40
+ raise BadManifest.new(
41
+ "entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{kind.inspect})",
42
+ )
43
+ end
44
+
45
+ frozen = src.freeze
46
+ if kind == "projection"
47
+ [frozen, frozen, nil]
48
+ else
49
+ [frozen, nil, frozen]
50
+ end
51
+ end
52
+
53
+ def self.parse_intake(src)
54
+ src ||= {}
55
+ [src["handler"], src["config"] || {}]
56
+ end
57
+
58
+ def self.resolve_format(raw, path, nested)
59
+ declared = raw["format"]
60
+ ext = File.extname(path)
61
+ inferred = Textus::Entry.infer_from_extension(ext)
62
+
63
+ if declared.nil?
64
+ return inferred if inferred
65
+ return "markdown" if ext == "" && nested
66
+ return "markdown" if ext == ""
67
+
68
+ return "markdown"
69
+ end
70
+
71
+ raise UsageError.new("entry '#{raw["key"]}': unknown format #{declared.inspect}") unless Textus::Entry.formats.include?(declared)
72
+
73
+ if ext != "" && inferred && inferred != declared
74
+ raise UsageError.new(
75
+ "entry '#{raw["key"]}': path extension #{ext.inspect} does not match declared format #{declared.inspect}",
76
+ )
77
+ end
78
+
79
+ declared
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ module Events
6
+ def self.call(entry)
7
+ pubsub_events = Textus::Hooks::Registry::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
8
+ entry.events.each_key do |evt|
9
+ next if pubsub_events.include?(evt.to_sym)
10
+
11
+ raise UsageError.new(
12
+ "entry '#{entry.key}': unknown event '#{evt}' in events: block. " \
13
+ "Known events: #{pubsub_events.join(", ")}.",
14
+ )
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ module FormatMatrix
6
+ def self.call(entry)
7
+ begin
8
+ Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested)
9
+ rescue UsageError => e
10
+ raise UsageError.new("entry '#{entry.key}': #{e.message}")
11
+ end
12
+
13
+ if entry.format == "text" && !entry.schema.nil?
14
+ raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
15
+ end
16
+
17
+ return unless entry.in_generator_zone? && entry.template.nil? && entry.generator.nil? &&
18
+ %w[markdown text].include?(entry.format) && !entry.nested
19
+
20
+ raise UsageError.new("entry '#{entry.key}': derived #{entry.format} entries require a template")
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end