textus 0.30.0 → 0.38.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +2 -241
  3. data/CHANGELOG.md +221 -0
  4. data/README.md +89 -69
  5. data/SPEC.md +359 -212
  6. data/docs/conventions.md +42 -37
  7. data/lib/textus/boot.rb +122 -87
  8. data/lib/textus/cli/group/{refresh.rb → fetch.rb} +4 -4
  9. data/lib/textus/cli/verb/build.rb +1 -1
  10. data/lib/textus/cli/verb/fetch.rb +14 -0
  11. data/lib/textus/cli/verb/{refresh_stale.rb → fetch_stale.rb} +3 -3
  12. data/lib/textus/cli/verb/get.rb +1 -1
  13. data/lib/textus/cli/verb/hooks.rb +1 -1
  14. data/lib/textus/cli/verb/mcp_serve.rb +8 -3
  15. data/lib/textus/cli/verb/propose.rb +28 -0
  16. data/lib/textus/cli/verb/pulse.rb +12 -3
  17. data/lib/textus/cli/verb/put.rb +1 -1
  18. data/lib/textus/cli/verb/rule_list.rb +7 -7
  19. data/lib/textus/cli/verb/schema.rb +1 -1
  20. data/lib/textus/cli/verb.rb +3 -2
  21. data/lib/textus/cli.rb +2 -2
  22. data/lib/textus/container.rb +1 -2
  23. data/lib/textus/contract.rb +106 -0
  24. data/lib/textus/cursor_store.rb +24 -0
  25. data/lib/textus/dispatcher.rb +6 -4
  26. data/lib/textus/doctor/check/audit_log.rb +1 -1
  27. data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +8 -8
  28. data/lib/textus/doctor/check/proposal_targets.rb +45 -0
  29. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  30. data/lib/textus/doctor.rb +2 -1
  31. data/lib/textus/domain/action.rb +3 -3
  32. data/lib/textus/domain/freshness/evaluator.rb +3 -3
  33. data/lib/textus/domain/freshness/policy.rb +2 -2
  34. data/lib/textus/domain/freshness.rb +7 -7
  35. data/lib/textus/domain/outcome.rb +2 -2
  36. data/lib/textus/domain/permission.rb +2 -10
  37. data/lib/textus/domain/policy/base_guards.rb +25 -0
  38. data/lib/textus/domain/policy/evaluation.rb +15 -0
  39. data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +1 -1
  40. data/lib/textus/domain/policy/guard.rb +35 -0
  41. data/lib/textus/domain/policy/guard_factory.rb +40 -0
  42. data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
  43. data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
  44. data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
  45. data/lib/textus/domain/policy/predicates/registry.rb +39 -0
  46. data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
  47. data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
  48. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
  49. data/lib/textus/domain/staleness/intake_check.rb +6 -6
  50. data/lib/textus/envelope.rb +2 -2
  51. data/lib/textus/errors.rb +25 -28
  52. data/lib/textus/hooks/event_bus.rb +4 -4
  53. data/lib/textus/init.rb +27 -18
  54. data/lib/textus/layout.rb +41 -0
  55. data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
  56. data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
  57. data/lib/textus/maintenance/migrate.rb +9 -0
  58. data/lib/textus/maintenance/rule_lint.rb +8 -0
  59. data/lib/textus/maintenance/zone_mv.rb +11 -1
  60. data/lib/textus/manifest/capabilities.rb +29 -0
  61. data/lib/textus/manifest/data.rb +14 -10
  62. data/lib/textus/manifest/policy.rb +37 -21
  63. data/lib/textus/manifest/rules.rb +16 -14
  64. data/lib/textus/manifest/schema.rb +48 -58
  65. data/lib/textus/manifest.rb +3 -3
  66. data/lib/textus/mcp/catalog.rb +72 -0
  67. data/lib/textus/mcp/server.rb +8 -5
  68. data/lib/textus/mcp/session.rb +3 -20
  69. data/lib/textus/mcp/tool_schemas.rb +6 -62
  70. data/lib/textus/mcp/tools.rb +4 -119
  71. data/lib/textus/ports/audit_log.rb +17 -15
  72. data/lib/textus/ports/audit_subscriber.rb +1 -1
  73. data/lib/textus/ports/build_lock.rb +1 -2
  74. data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
  75. data/lib/textus/ports/{refresh → fetch}/lock.rb +2 -2
  76. data/lib/textus/projection.rb +1 -1
  77. data/lib/textus/read/audit.rb +3 -3
  78. data/lib/textus/read/boot.rb +6 -0
  79. data/lib/textus/read/freshness.rb +9 -9
  80. data/lib/textus/read/get.rb +16 -8
  81. data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
  82. data/lib/textus/read/list.rb +8 -0
  83. data/lib/textus/read/policy_explain.rb +14 -10
  84. data/lib/textus/read/pulse.rb +12 -4
  85. data/lib/textus/read/rules.rb +24 -0
  86. data/lib/textus/read/schema_envelope.rb +7 -0
  87. data/lib/textus/read/validator.rb +1 -1
  88. data/lib/textus/role.rb +6 -2
  89. data/lib/textus/schema/tools.rb +5 -5
  90. data/lib/textus/session.rb +24 -0
  91. data/lib/textus/store.rb +11 -0
  92. data/lib/textus/version.rb +1 -1
  93. data/lib/textus/write/accept.rb +19 -55
  94. data/lib/textus/write/delete.rb +14 -2
  95. data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +14 -6
  96. data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
  97. data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +29 -14
  98. data/lib/textus/write/mv.rb +15 -3
  99. data/lib/textus/write/propose.rb +46 -0
  100. data/lib/textus/write/put.rb +26 -2
  101. data/lib/textus/write/reject.rb +11 -5
  102. data/lib/textus.rb +4 -0
  103. metadata +36 -21
  104. data/lib/textus/cli/verb/refresh.rb +0 -14
  105. data/lib/textus/domain/authorizer.rb +0 -37
  106. data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
  107. data/lib/textus/domain/policy/promote.rb +0 -26
  108. data/lib/textus/domain/policy/promotion.rb +0 -57
  109. data/lib/textus/manifest/role_kinds.rb +0 -21
  110. data/lib/textus/write/authority_gate.rb +0 -24
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Textus
6
+ module Domain
7
+ module Policy
8
+ module Predicates
9
+ # Parameterized predicate: the entry must have been written within
10
+ # `duration` of now. Duration strings ("1h", "30m", "7d") parse via
11
+ # Domain::Duration.seconds. Passes when no envelope exists yet.
12
+ class FreshWithin
13
+ attr_reader :reason
14
+
15
+ def initialize(duration:, now: nil)
16
+ @seconds = Textus::Domain::Duration.seconds(duration)
17
+ @now = now
18
+ end
19
+
20
+ def name = "fresh_within"
21
+
22
+ def call(eval)
23
+ return true if eval.envelope.nil? || @seconds.nil?
24
+
25
+ written = written_at(eval.envelope)
26
+ return true if written.nil?
27
+
28
+ now = @now || Textus::Ports::Clock.now
29
+ return true if now - written <= @seconds
30
+
31
+ @reason = "entry older than #{@seconds}s (written #{written.iso8601})"
32
+ false
33
+ end
34
+
35
+ private
36
+
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).
41
+ def written_at(envelope)
42
+ raw = envelope.freshness&.checked_at ||
43
+ envelope.meta&.dig("last_fetched_at") ||
44
+ envelope.meta&.dig("generated_at")
45
+ return raw if raw.is_a?(Time)
46
+ return nil if raw.nil?
47
+
48
+ begin
49
+ Time.parse(raw.to_s)
50
+ rescue StandardError
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ module Predicates
7
+ # The single source of truth for the predicate vocabulary
8
+ # (ADR 0031 §3). Replaces both Promote::KNOWN and Promotion::REGISTRY.
9
+ # Each entry is name => ->(params:, schemas:) { predicate }.
10
+ module Registry
11
+ ENTRIES = {
12
+ "zone_writable_by" => ->(**) { ZoneWritableBy.new },
13
+ "author_held" => ->(**) { AuthorHeld.new },
14
+ "target_is_canon" => ->(**) { TargetIsCanon.new },
15
+ "schema_valid" => ->(schemas:, **) { SchemaValid.new(schemas: schemas) },
16
+ "etag_match" => ->(params:, **) { EtagMatch.new(if_etag: params) },
17
+ "fresh_within" => ->(params:, **) { FreshWithin.new(duration: params) },
18
+ }.freeze
19
+
20
+ # Accepts either "name" or { "name" => params }.
21
+ def self.build(spec, schemas:)
22
+ name, params =
23
+ if spec.is_a?(Hash)
24
+ spec.first
25
+ else
26
+ [spec.to_s, nil]
27
+ end
28
+ ctor = ENTRIES[name.to_s] or raise Textus::UsageError.new(
29
+ "unknown guard predicate: '#{name}' (known: #{ENTRIES.keys.join(", ")})",
30
+ )
31
+ ctor.call(params: params, schemas: schemas)
32
+ end
33
+
34
+ def self.known = ENTRIES.keys
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,48 +1,59 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Textus
2
4
  module Domain
3
5
  module Policy
4
6
  module Predicates
7
+ # Predicate: the entry's effective frontmatter satisfies the schema
8
+ # bound to the target key. For accept, the frontmatter lives under
9
+ # envelope.meta["frontmatter"]; for a direct put it is envelope.meta.
5
10
  class SchemaValid
6
11
  attr_reader :reason
7
12
 
8
- def name
9
- "schema_valid"
13
+ def initialize(schemas:)
14
+ @schemas = schemas
10
15
  end
11
16
 
12
- def call(entry:, schemas:, manifest:) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
13
- return true if entry.nil? || manifest.nil? || schemas.nil?
17
+ def name = "schema_valid"
18
+
19
+ def call(eval)
20
+ manifest = eval.manifest
21
+ return true if eval.envelope.nil? || manifest.nil? || @schemas.nil?
14
22
 
15
- target_key = entry.meta&.dig("proposal", "target_key")
23
+ target_key = eval.target
16
24
  return true unless target_key
17
25
 
18
26
  mentry = manifest.resolver.resolve(target_key).entry
19
27
  schema_ref = mentry&.schema
20
28
  return true unless schema_ref
21
29
 
22
- schema = schemas.fetch_or_nil(schema_ref)
30
+ schema = @schemas.fetch_or_nil(schema_ref)
23
31
  return true unless schema
24
32
 
25
- frontmatter = entry.meta&.dig("frontmatter") || {}
33
+ frontmatter =
34
+ eval.envelope.meta&.dig("frontmatter") || eval.envelope.meta || {}
26
35
  begin
27
36
  schema.validate!(frontmatter)
37
+ true
28
38
  rescue Textus::SchemaViolation => e
29
- @reason = e.message.dup
30
- d = e.details
31
- if d.is_a?(Hash)
32
- if d["missing"]
33
- @reason = "missing required fields: #{Array(d["missing"]).join(", ")}"
34
- elsif d["field"]
35
- @reason = "field '#{d["field"]}': #{d["reason"]}"
36
- end
37
- end
38
- return false
39
+ @reason = humanize(e)
40
+ false
39
41
  end
40
-
41
- true
42
42
  rescue StandardError => e
43
43
  @reason = "schema validation error: #{e.message}"
44
44
  false
45
45
  end
46
+
47
+ private
48
+
49
+ def humanize(err)
50
+ d = err.details
51
+ return err.message.dup unless d.is_a?(Hash)
52
+ return "missing required fields: #{Array(d["missing"]).join(", ")}" if d["missing"]
53
+ return "field '#{d["field"]}': #{d["reason"]}" if d["field"]
54
+
55
+ err.message.dup
56
+ end
46
57
  end
47
58
  end
48
59
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ module Predicates
7
+ # Predicate: a proposal may only target a `canon` zone (ADR 0035). Runs
8
+ # on the `accept` floor, where Evaluation#target is the proposal's
9
+ # resolved target_key. Refuses promotion into workspace/derived/
10
+ # quarantine/queue — the queue→canon path is the only coherent one.
11
+ # No bespoke #error; failures accumulate into GuardFailed (ADR 0031).
12
+ class TargetIsCanon
13
+ attr_reader :reason
14
+
15
+ def name = "target_is_canon"
16
+
17
+ def call(eval)
18
+ zone = eval.manifest.resolver.resolve(eval.target).entry.zone
19
+ kind = eval.manifest.policy.declared_kind(zone.to_s)
20
+ return true if kind == :canon
21
+
22
+ @reason = "proposal target '#{eval.target}' is in zone '#{zone}' " \
23
+ "(kind: #{kind || "none"}); proposals may only target a canon zone"
24
+ false
25
+ rescue Textus::UnknownKey
26
+ @reason = "proposal target '#{eval.target}' resolves to no declared entry"
27
+ false
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ module Predicates
7
+ # Predicate #0 of every write guard. Wraps the post-0.31.0 capability
8
+ # topology gate (role.can ⊇ verb_for(zone.kind)). On failure, #error
9
+ # raises the capability-shaped WriteForbidden so the topology refusal
10
+ # — textus's signature product feature — is unchanged.
11
+ class ZoneWritableBy
12
+ attr_reader :reason
13
+
14
+ def name = "zone_writable_by"
15
+
16
+ def call(eval)
17
+ manifest = eval.manifest
18
+ @mentry = manifest.resolver.resolve(eval.target).entry
19
+ return true if manifest.policy.permission_for(@mentry.zone.to_s).allows_write?(eval.actor)
20
+
21
+ @verb = manifest.policy.verb_for_zone(@mentry.zone) # capability the kind requires
22
+ @holders = manifest.policy.roles_with_capability(@verb)
23
+ @reason = "zone '#{@mentry.zone}' needs capability '#{@verb}'; '#{eval.actor}' lacks it"
24
+ false
25
+ end
26
+
27
+ # Matches the capability-shaped WriteForbidden landed by ADR 0030
28
+ # Task 3:
29
+ # WriteForbidden.new(key, zone, verb:, holders:)
30
+ # → "writing '<k>' (zone '<z>') needs capability '<verb>'",
31
+ # hint: "held by: <holders>; pass --as=<role>".
32
+ def error(_eval)
33
+ Textus::WriteForbidden.new(@mentry.key, @mentry.zone, verb: @verb, holders: @holders)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -15,7 +15,7 @@ module Textus
15
15
  def rows_for(mentry)
16
16
  return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
17
17
 
18
- ttl = @manifest.rules.for(mentry.key).refresh&.ttl_seconds
18
+ ttl = @manifest.rules.for(mentry.key).fetch&.ttl_seconds
19
19
  return [] unless ttl
20
20
 
21
21
  path = Textus::Key::Path.resolve(@manifest.data, mentry)
@@ -26,17 +26,17 @@ module Textus
26
26
  private
27
27
 
28
28
  def ttl_reason(mentry, path, ttl)
29
- return "never refreshed" unless @file_stat.exists?(path)
29
+ return "never fetched" unless @file_stat.exists?(path)
30
30
 
31
- last_str = last_refreshed_of(mentry, path)
32
- return "never refreshed (no last_refreshed_at)" if last_str.nil?
31
+ last_str = last_fetched_of(mentry, path)
32
+ return "never fetched (no last_fetched_at)" if last_str.nil?
33
33
 
34
34
  last = parse_time(last_str)
35
35
  "ttl exceeded (#{ttl}s)" if last.nil? || (@clock.now - last) > ttl
36
36
  end
37
37
 
38
- def last_refreshed_of(mentry, path)
39
- Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_refreshed_at"]
38
+ def last_fetched_of(mentry, path)
39
+ Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_fetched_at"]
40
40
  end
41
41
 
42
42
  def parse_time(str)
@@ -55,10 +55,10 @@ module Textus
55
55
  freshness.stale == true
56
56
  end
57
57
 
58
- def refreshing?
58
+ def fetching?
59
59
  return false if freshness.nil?
60
60
 
61
- freshness.refreshing == true
61
+ freshness.fetching == true
62
62
  end
63
63
  end
64
64
  end
data/lib/textus/errors.rb CHANGED
@@ -90,39 +90,21 @@ module Textus
90
90
  end
91
91
 
92
92
  class WriteForbidden < Error
93
- def initialize(k, z, writers: nil)
94
- writers_str =
95
- if writers && !writers.empty?
96
- writers.join(", ")
93
+ def initialize(k, z, verb: nil, holders: nil)
94
+ holders_str =
95
+ if holders && !holders.empty?
96
+ holders.join(", ")
97
97
  else
98
- "the role(s) listed in the manifest 'write_policy:'"
98
+ "no declared role"
99
99
  end
100
100
  details = { "key" => k, "zone" => z }
101
- details["writers"] = writers if writers
101
+ details["verb"] = verb if verb
102
+ details["holders"] = holders if holders
102
103
  super(
103
104
  "write_forbidden",
104
- "zone '#{z}' is not agent-writable for key '#{k}'",
105
+ "writing '#{k}' (zone '#{z}') needs capability '#{verb}'",
105
106
  details: details,
106
- hint: "this zone is writable by #{writers_str}; pass --as=<role>",
107
- )
108
- end
109
- end
110
-
111
- class ReadForbidden < Error
112
- def initialize(k, z, readers: nil)
113
- readers_str =
114
- if readers && !readers.empty?
115
- readers.join(", ")
116
- else
117
- "the role(s) listed in the manifest 'read_policy:'"
118
- end
119
- details = { "key" => k, "zone" => z }
120
- details["readers"] = readers if readers
121
- super(
122
- "read_forbidden",
123
- "zone '#{z}' is not readable by role for key '#{k}'",
124
- details: details,
125
- hint: "this zone is readable by #{readers_str}; pass --as=<role>",
107
+ hint: "held by: #{holders_str}; pass --as=<role>",
126
108
  )
127
109
  end
128
110
  end
@@ -163,7 +145,7 @@ module Textus
163
145
  "invalid_role",
164
146
  message || "role '#{r}' is not declared in any zone",
165
147
  details: { "role" => r },
166
- hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under zones[].write_policy",
148
+ hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under roles: (each with a can: list)",
167
149
  )
168
150
  end
169
151
  end
@@ -204,6 +186,21 @@ module Textus
204
186
  def initialize(m) = super("proposal_error", m)
205
187
  end
206
188
 
189
+ class GuardFailed < Error
190
+ def initialize(failed)
191
+ # failed: [[predicate_name, reason], ...]
192
+ rows = failed.map { |name, reason| { "predicate" => name, "reason" => reason } }
193
+ names = failed.map(&:first)
194
+ super(
195
+ "guard_failed",
196
+ "guard refused crossing: #{failed.map { |n, r| "#{n} (#{r})" }.join("; ")}",
197
+ details: { "failed" => rows },
198
+ hint: "run 'textus policy explain <key> --output=json' to see the full guard; " \
199
+ "unmet: #{names.join(", ")}",
200
+ )
201
+ end
202
+ end
203
+
207
204
  class FlagRenamed < Error
208
205
  def initialize(old_flag, new_flag)
209
206
  super(
@@ -10,16 +10,16 @@ module Textus
10
10
  EVENTS = {
11
11
  entry_put: %i[ctx key envelope],
12
12
  entry_deleted: %i[ctx key],
13
- entry_refreshed: %i[ctx key envelope change],
13
+ entry_fetched: %i[ctx key envelope change],
14
14
  entry_renamed: %i[ctx key from_key to_key envelope],
15
15
  build_completed: %i[ctx key envelope sources],
16
16
  proposal_accepted: %i[ctx key target_key],
17
17
  proposal_rejected: %i[ctx key target_key],
18
18
  file_published: %i[ctx key envelope source target],
19
19
  store_loaded: %i[ctx],
20
- refresh_started: %i[ctx key mode],
21
- refresh_failed: %i[ctx key error_class error_message],
22
- refresh_backgrounded: %i[ctx key started_at budget_ms],
20
+ fetch_started: %i[ctx key mode],
21
+ fetch_failed: %i[ctx key error_class error_message],
22
+ fetch_backgrounded: %i[ctx key started_at budget_ms],
23
23
  }.freeze
24
24
 
25
25
  RPC_EVENTS = %i[resolve_intake transform_rows validate].freeze
data/lib/textus/init.rb CHANGED
@@ -2,20 +2,25 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  module Init
5
- ZONES = %w[identity working intake review output].freeze
5
+ ZONES = %w[knowledge notebook feeds proposals artifacts].freeze
6
6
 
7
7
  DEFAULT_MANIFEST = <<~YAML
8
8
  version: textus/3
9
+ roles:
10
+ - { name: human, can: [author, propose] }
11
+ - { name: agent, can: [propose, keep] }
12
+ - { name: automation, can: [fetch, build] }
9
13
  zones:
10
- - { name: identity, kind: origin, write_policy: [human], read_policy: [all] }
11
- - { name: working, kind: origin, write_policy: [human], read_policy: [all] }
12
- - { name: intake, kind: quarantine, write_policy: [runner], read_policy: [all] }
13
- - { name: review, kind: queue, write_policy: [agent, human], read_policy: [all] }
14
- - { name: output, kind: derived, write_policy: [builder], read_policy: [all] }
14
+ - { name: knowledge, kind: canon, desc: "the maintained source of truth (identity.* lives here)" }
15
+ - { name: notebook, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
16
+ - { name: feeds, kind: quarantine, desc: "external inputs pulled in" }
17
+ - { name: proposals, kind: queue, desc: "changes awaiting your accept" }
18
+ - { name: artifacts, kind: derived, desc: "computed, shippable outputs" }
15
19
  entries:
16
- - { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self, kind: leaf }
17
- - { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true, kind: nested }
18
- - { key: review.notes, path: review/notes, zone: review, schema: null, owner: agent:self, nested: true, kind: nested }
20
+ - { key: knowledge.identity, path: knowledge/identity.md, zone: knowledge, schema: null, owner: human:self, kind: leaf }
21
+ - { key: knowledge.notes, path: knowledge/notes, zone: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
22
+ - { key: notebook.notes, path: notebook/notes, zone: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
23
+ - { key: proposals.notes, path: proposals/notes, zone: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
19
24
  YAML
20
25
 
21
26
  HOOKS_README = <<~MD
@@ -31,12 +36,12 @@ module Textus
31
36
  ```ruby
32
37
  Textus.hook do |reg|
33
38
  reg.on(:resolve_intake, :my_source) do |config:, args:, **|
34
- { _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
39
+ { _meta: { "last_fetched_at" => Time.now.utc.iso8601 }, body: "…" }
35
40
  end
36
41
 
37
42
  reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
38
43
  reg.on(:validate, :my_check) { |caps:, **| [] }
39
- reg.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
44
+ reg.on(:entry_put, :my_listener, keys: ["knowledge.*"]) { |key:, envelope:, **| }
40
45
 
41
46
  # Run a side-effect every time textus writes a file to your repo:
42
47
  reg.on(:file_published, :notify) do |key:, target:, **|
@@ -51,25 +56,25 @@ module Textus
51
56
 
52
57
  ```yaml
53
58
  entries:
54
- - key: intake.foo
59
+ - key: feeds.foo
55
60
  kind: intake
56
- path: intake/foo.md
57
- zone: intake
61
+ path: feeds/foo.md
62
+ zone: feeds
58
63
  intake:
59
64
  handler: my_source
60
65
 
61
66
  rules:
62
- - match: intake.foo
63
- refresh:
67
+ - match: feeds.foo
68
+ fetch:
64
69
  ttl: 10m
65
70
  on_stale: timed_sync # warn | sync | timed_sync (default: warn)
66
71
  ```
67
72
 
68
73
  Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
69
- :entry_put, :entry_deleted, :entry_refreshed, :entry_renamed,
74
+ :entry_put, :entry_deleted, :entry_fetched, :entry_renamed,
70
75
  :build_completed, :proposal_accepted, :proposal_rejected,
71
76
  :file_published, :store_loaded,
72
- :refresh_started, :refresh_failed, :refresh_backgrounded (pub-sub — return discarded)
77
+ :fetch_started, :fetch_failed, :fetch_backgrounded (pub-sub — return discarded)
73
78
 
74
79
  See SPEC.md §5.10 for the full table.
75
80
  MD
@@ -87,6 +92,10 @@ module Textus
87
92
  end
88
93
  File.write(File.join(target_root, "hooks", "README.md"), HOOKS_README)
89
94
  File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
95
+ FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
96
+ FileUtils.mkdir_p(Textus::Layout.state(target_root))
97
+ FileUtils.mkdir_p(Textus::Layout.locks(target_root))
98
+ File.write(File.join(target_root, ".gitignore"), Textus::Layout::GITIGNORE)
90
99
  { "protocol" => PROTOCOL, "initialized" => target_root }
91
100
  end
92
101
  end
@@ -0,0 +1,41 @@
1
+ module Textus
2
+ # Single source of truth for every path textus owns under a store root.
3
+ # All disposable runtime state nests under <root>/.run/ so the
4
+ # tracked/disposable boundary is a directory boundary. ADR 0038.
5
+ module Layout
6
+ RUN = ".run"
7
+
8
+ def self.run(root)
9
+ File.join(root, RUN)
10
+ end
11
+
12
+ def self.state(root)
13
+ File.join(run(root), "state")
14
+ end
15
+
16
+ def self.cursor(root, role)
17
+ File.join(state(root), "cursor.#{role}")
18
+ end
19
+
20
+ def self.locks(root)
21
+ File.join(run(root), "locks")
22
+ end
23
+
24
+ def self.build_lock(root)
25
+ File.join(run(root), "build.lock")
26
+ end
27
+
28
+ def self.audit_dir(root)
29
+ File.join(run(root), "audit")
30
+ end
31
+
32
+ def self.audit_log(root)
33
+ File.join(audit_dir(root), "audit.log")
34
+ end
35
+
36
+ GITIGNORE = <<~GITIGNORE
37
+ # textus runtime artifacts — safe to delete, never commit
38
+ #{RUN}/
39
+ GITIGNORE
40
+ end
41
+ end
@@ -2,6 +2,15 @@ module Textus
2
2
  module Maintenance
3
3
  # Bulk-delete every leaf key under `prefix`.
4
4
  class KeyDeletePrefix
5
+ extend Textus::Contract::DSL
6
+
7
+ verb :key_delete_prefix
8
+ summary "Bulk-delete every leaf key under prefix."
9
+ surfaces :cli, :ruby, :mcp
10
+ arg :prefix, String, required: true
11
+ arg :dry_run, :boolean
12
+ response(&:to_h)
13
+
5
14
  def initialize(container:, call:)
6
15
  @container = container
7
16
  @call = call
@@ -3,6 +3,16 @@ module Textus
3
3
  # Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
4
4
  # Calls Write::Mv directly for each entry — emits one audit row per file moved.
5
5
  class KeyMvPrefix
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :key_mv_prefix
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
11
+ arg :from_prefix, String, required: true
12
+ arg :to_prefix, String, required: true
13
+ arg :dry_run, :boolean
14
+ response(&:to_h)
15
+
6
16
  def initialize(container:, call:)
7
17
  @container = container
8
18
  @call = call
@@ -5,6 +5,15 @@ module Textus
5
5
  # Loads a YAML migration plan and dispatches each op to the
6
6
  # appropriate Maintenance use case. Concatenates resulting Plans.
7
7
  class Migrate
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :migrate
11
+ summary "Run a YAML migration plan (multi-op)."
12
+ surfaces :cli, :ruby, :mcp
13
+ arg :plan_yaml, String, required: true
14
+ arg :dry_run, :boolean
15
+ response(&:to_h)
16
+
8
17
  def initialize(container:, call:)
9
18
  @container = container
10
19
  @call = call
@@ -6,6 +6,14 @@ module Textus
6
6
  # YAML string. Returns a Plan describing rule additions/removals/
7
7
  # changes. Does NOT write anything.
8
8
  class RuleLint
9
+ extend Textus::Contract::DSL
10
+
11
+ verb :rule_lint
12
+ summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
13
+ surfaces :cli, :ruby, :mcp
14
+ arg :candidate_yaml, String, required: true
15
+ response(&:to_h)
16
+
9
17
  def initialize(container:, call:)
10
18
  @container = container
11
19
  @call = call
@@ -6,6 +6,16 @@ module Textus
6
6
  # the `zone:` field on every entry under the old zone, and moves
7
7
  # every file from zones/<old>/ to zones/<new>/.
8
8
  class ZoneMv
9
+ extend Textus::Contract::DSL
10
+
11
+ verb :zone_mv
12
+ summary "Rename a zone — manifest + files. Refuses if destination exists."
13
+ surfaces :cli, :ruby, :mcp
14
+ arg :from, String, required: true
15
+ arg :to, String, required: true
16
+ arg :dry_run, :boolean
17
+ response(&:to_h)
18
+
9
19
  def initialize(container:, call:)
10
20
  @container = container
11
21
  @call = call
@@ -15,7 +25,7 @@ module Textus
15
25
 
16
26
  def call(from:, to:, dry_run: false)
17
27
  raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
18
- raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.zones.key?(from)
28
+ raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
19
29
 
20
30
  dest_dir = File.join(@root, "zones", to)
21
31
  raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
@@ -0,0 +1,29 @@
1
+ module Textus
2
+ class Manifest
3
+ # Resolves a manifest's `roles:` block (or the absence of one) into a
4
+ # capability map: { role_name => [verbs] }. Verbs are a subset of the
5
+ # closed capability set (Schema::CAPABILITIES). See ADR 0030.
6
+ module Capabilities
7
+ # Fallback role set for a manifest that omits `roles:` entirely. Agent
8
+ # is intentionally minimal here (`propose` only) — narrower than the
9
+ # `textus init` scaffold, which declares `agent: [propose, keep]` so the
10
+ # default `notebook` workspace is writable. A roles-less manifest that
11
+ # declares a `kind: workspace` zone is therefore rejected at load (no
12
+ # `keep`-holder); declare `roles:` to opt into a workspace lane (ADR 0033).
13
+ DEFAULT_MAPPING = {
14
+ "human" => %w[author propose].freeze,
15
+ "agent" => %w[propose].freeze,
16
+ "automation" => %w[fetch build].freeze,
17
+ }.freeze
18
+
19
+ # Returns { role_name => [verbs] }. When `roles:` is declared we use
20
+ # exactly that; defaults are *not* layered in (declaring roles is an
21
+ # opt-in to a fully user-defined vocabulary).
22
+ def self.resolve(raw_roles)
23
+ return DEFAULT_MAPPING if raw_roles.nil?
24
+
25
+ raw_roles.to_h { |r| [r["name"], Array(r["can"]).freeze] }.freeze
26
+ end
27
+ end
28
+ end
29
+ end