textus 0.52.0 → 0.53.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 (254) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +62 -187
  5. data/docs/architecture/README.md +88 -77
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +53 -0
  8. data/lib/textus/action/audit.rb +133 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +30 -22
  11. data/lib/textus/action/boot.rb +26 -0
  12. data/lib/textus/action/data_mv.rb +71 -0
  13. data/lib/textus/action/deps.rb +48 -0
  14. data/lib/textus/action/doctor.rb +26 -0
  15. data/lib/textus/action/drain.rb +41 -0
  16. data/lib/textus/action/enqueue.rb +55 -0
  17. data/lib/textus/action/get.rb +80 -0
  18. data/lib/textus/action/jobs.rb +38 -0
  19. data/lib/textus/action/key_delete.rb +46 -0
  20. data/lib/textus/action/key_delete_prefix.rb +46 -0
  21. data/lib/textus/action/key_mv.rb +143 -0
  22. data/lib/textus/action/key_mv_prefix.rb +59 -0
  23. data/lib/textus/action/list.rb +44 -0
  24. data/lib/textus/action/propose.rb +54 -0
  25. data/lib/textus/action/published.rb +26 -0
  26. data/lib/textus/action/pulse/scanner.rb +118 -0
  27. data/lib/textus/action/pulse.rb +87 -0
  28. data/lib/textus/action/put.rb +63 -0
  29. data/lib/textus/action/rdeps.rb +49 -0
  30. data/lib/textus/action/reject.rb +49 -0
  31. data/lib/textus/action/rule_explain.rb +95 -0
  32. data/lib/textus/action/rule_lint.rb +70 -0
  33. data/lib/textus/action/rule_list.rb +46 -0
  34. data/lib/textus/action/schema_envelope.rb +31 -0
  35. data/lib/textus/action/uid.rb +35 -0
  36. data/lib/textus/action/where.rb +38 -0
  37. data/lib/textus/action/write_verb.rb +58 -0
  38. data/lib/textus/background/job/base.rb +27 -0
  39. data/lib/textus/background/job/materialize.rb +31 -0
  40. data/lib/textus/background/job/refresh.rb +22 -0
  41. data/lib/textus/background/job/sweep.rb +31 -0
  42. data/lib/textus/background/job.rb +19 -0
  43. data/lib/textus/background/plan.rb +9 -0
  44. data/lib/textus/background/planner/plan.rb +113 -0
  45. data/lib/textus/{maintenance → background}/retention/apply.rb +7 -9
  46. data/lib/textus/background/worker.rb +67 -0
  47. data/lib/textus/boot.rb +53 -45
  48. data/lib/textus/command.rb +36 -0
  49. data/lib/textus/container.rb +1 -1
  50. data/lib/textus/{domain → core}/duration.rb +1 -1
  51. data/lib/textus/{domain → core}/freshness/evaluator.rb +11 -11
  52. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  53. data/lib/textus/{domain → core}/freshness.rb +2 -2
  54. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  55. data/lib/textus/{domain → core}/retention.rb +2 -2
  56. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  57. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  58. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  59. data/lib/textus/doctor/check/hooks.rb +11 -18
  60. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  61. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  62. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  63. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  64. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  65. data/lib/textus/doctor/check.rb +12 -9
  66. data/lib/textus/{read → doctor}/validator.rb +22 -13
  67. data/lib/textus/doctor.rb +6 -6
  68. data/lib/textus/envelope/io/writer.rb +65 -36
  69. data/lib/textus/envelope.rb +5 -3
  70. data/lib/textus/errors.rb +17 -9
  71. data/lib/textus/events.rb +21 -0
  72. data/lib/textus/gate/auth.rb +181 -0
  73. data/lib/textus/gate.rb +114 -0
  74. data/lib/textus/init/templates/machine_intake.rb +39 -35
  75. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  76. data/lib/textus/init.rb +90 -73
  77. data/lib/textus/key/path.rb +9 -2
  78. data/lib/textus/layout.rb +13 -0
  79. data/lib/textus/manifest/data.rb +14 -14
  80. data/lib/textus/manifest/entry/base.rb +15 -11
  81. data/lib/textus/manifest/entry/parser.rb +6 -6
  82. data/lib/textus/manifest/entry/produced.rb +3 -2
  83. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  84. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  85. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  86. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  87. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  88. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  89. data/lib/textus/manifest/policy/react.rb +30 -0
  90. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  91. data/lib/textus/{domain → manifest}/policy/source.rb +24 -19
  92. data/lib/textus/manifest/policy.rb +36 -48
  93. data/lib/textus/manifest/resolver.rb +3 -2
  94. data/lib/textus/manifest/rules.rb +4 -4
  95. data/lib/textus/manifest/schema/keys.rb +17 -11
  96. data/lib/textus/manifest/schema/validator.rb +24 -22
  97. data/lib/textus/manifest/schema/vocabulary.rb +1 -1
  98. data/lib/textus/manifest/schema.rb +2 -2
  99. data/lib/textus/manifest.rb +2 -2
  100. data/lib/textus/{produce → pipeline}/acquire/handler.rb +2 -2
  101. data/lib/textus/{produce → pipeline}/acquire/intake.rb +22 -20
  102. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  103. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  104. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  105. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  106. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  107. data/lib/textus/{produce → pipeline}/engine.rb +7 -5
  108. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  109. data/lib/textus/ports/audit_log.rb +31 -5
  110. data/lib/textus/ports/audit_subscriber.rb +4 -4
  111. data/lib/textus/{domain/jobs → ports/queue}/job.rb +19 -12
  112. data/lib/textus/ports/queue.rb +1 -1
  113. data/lib/textus/ports/sentinel_store.rb +2 -2
  114. data/lib/textus/ports/watcher_lock.rb +48 -0
  115. data/lib/textus/projection.rb +8 -8
  116. data/lib/textus/schema/tools.rb +4 -3
  117. data/lib/textus/session.rb +6 -3
  118. data/lib/textus/step/base.rb +35 -0
  119. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  120. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  121. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  122. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  123. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  124. data/lib/textus/step/builtin.rb +22 -0
  125. data/lib/textus/{hooks → step}/catalog.rb +3 -3
  126. data/lib/textus/{hooks → step}/context.rb +15 -13
  127. data/lib/textus/step/discovery.rb +24 -0
  128. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  129. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  130. data/lib/textus/step/fetch.rb +13 -0
  131. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  132. data/lib/textus/step/loader.rb +108 -0
  133. data/lib/textus/step/observe.rb +31 -0
  134. data/lib/textus/step/registry_store.rb +66 -0
  135. data/lib/textus/{hooks → step}/signature.rb +1 -1
  136. data/lib/textus/step/transform.rb +12 -0
  137. data/lib/textus/step/validate.rb +11 -0
  138. data/lib/textus/step.rb +10 -0
  139. data/lib/textus/store.rb +17 -15
  140. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  141. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  142. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  143. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  144. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  145. data/lib/textus/surfaces/cli/group.rb +50 -0
  146. data/lib/textus/surfaces/cli/runner.rb +236 -0
  147. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  148. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  149. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  150. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  151. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  152. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  153. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  155. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  156. data/lib/textus/surfaces/cli/verb.rb +111 -0
  157. data/lib/textus/surfaces/cli.rb +148 -0
  158. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  159. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  160. data/lib/textus/surfaces/mcp/server.rb +145 -0
  161. data/lib/textus/surfaces/mcp/session.rb +9 -0
  162. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  163. data/lib/textus/surfaces/mcp.rb +8 -0
  164. data/lib/textus/surfaces/role_scope.rb +38 -0
  165. data/lib/textus/surfaces/watcher.rb +38 -0
  166. data/lib/textus/version.rb +1 -1
  167. data/lib/textus.rb +64 -22
  168. metadata +132 -118
  169. data/lib/textus/cli/group/hook.rb +0 -9
  170. data/lib/textus/cli/group/key.rb +0 -9
  171. data/lib/textus/cli/group/mcp.rb +0 -9
  172. data/lib/textus/cli/group/rule.rb +0 -9
  173. data/lib/textus/cli/group/schema.rb +0 -9
  174. data/lib/textus/cli/group/zone.rb +0 -9
  175. data/lib/textus/cli/group.rb +0 -48
  176. data/lib/textus/cli/runner.rb +0 -193
  177. data/lib/textus/cli/verb/doctor.rb +0 -17
  178. data/lib/textus/cli/verb/get.rb +0 -18
  179. data/lib/textus/cli/verb/hook_run.rb +0 -48
  180. data/lib/textus/cli/verb/hooks.rb +0 -50
  181. data/lib/textus/cli/verb/init.rb +0 -18
  182. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  183. data/lib/textus/cli/verb/put.rb +0 -30
  184. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  185. data/lib/textus/cli/verb/schema_init.rb +0 -19
  186. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  187. data/lib/textus/cli/verb/serve.rb +0 -19
  188. data/lib/textus/cli/verb.rb +0 -116
  189. data/lib/textus/cli.rb +0 -138
  190. data/lib/textus/dispatcher.rb +0 -54
  191. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  192. data/lib/textus/domain/action.rb +0 -9
  193. data/lib/textus/domain/jobs/registry.rb +0 -37
  194. data/lib/textus/domain/permission.rb +0 -7
  195. data/lib/textus/domain/policy/base_guards.rb +0 -25
  196. data/lib/textus/domain/policy/evaluation.rb +0 -15
  197. data/lib/textus/domain/policy/guard.rb +0 -35
  198. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  199. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  200. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  201. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  202. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  203. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  204. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  205. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  206. data/lib/textus/hooks/builtin.rb +0 -70
  207. data/lib/textus/hooks/loader.rb +0 -54
  208. data/lib/textus/hooks/rpc_registry.rb +0 -43
  209. data/lib/textus/jobs/handlers.rb +0 -62
  210. data/lib/textus/jobs/scheduler.rb +0 -36
  211. data/lib/textus/jobs/seeder.rb +0 -57
  212. data/lib/textus/maintenance/drain.rb +0 -42
  213. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  214. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/serve.rb +0 -30
  217. data/lib/textus/maintenance/worker.rb +0 -74
  218. data/lib/textus/maintenance/zone_mv.rb +0 -64
  219. data/lib/textus/maintenance.rb +0 -15
  220. data/lib/textus/mcp/catalog.rb +0 -70
  221. data/lib/textus/mcp/errors.rb +0 -32
  222. data/lib/textus/mcp/server.rb +0 -138
  223. data/lib/textus/mcp/session.rb +0 -7
  224. data/lib/textus/mcp/tool_schemas.rb +0 -15
  225. data/lib/textus/mcp.rb +0 -6
  226. data/lib/textus/mustache.rb +0 -117
  227. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  228. data/lib/textus/produce/events.rb +0 -36
  229. data/lib/textus/read/audit.rb +0 -130
  230. data/lib/textus/read/boot.rb +0 -26
  231. data/lib/textus/read/capabilities.rb +0 -70
  232. data/lib/textus/read/deps.rb +0 -38
  233. data/lib/textus/read/doctor.rb +0 -27
  234. data/lib/textus/read/freshness.rb +0 -152
  235. data/lib/textus/read/get.rb +0 -73
  236. data/lib/textus/read/jobs.rb +0 -31
  237. data/lib/textus/read/list.rb +0 -24
  238. data/lib/textus/read/published.rb +0 -22
  239. data/lib/textus/read/pulse.rb +0 -98
  240. data/lib/textus/read/rdeps.rb +0 -39
  241. data/lib/textus/read/rule_explain.rb +0 -96
  242. data/lib/textus/read/rule_list.rb +0 -54
  243. data/lib/textus/read/schema_envelope.rb +0 -25
  244. data/lib/textus/read/uid.rb +0 -29
  245. data/lib/textus/read/validate_all.rb +0 -36
  246. data/lib/textus/read/where.rb +0 -24
  247. data/lib/textus/role_scope.rb +0 -78
  248. data/lib/textus/write/accept.rb +0 -58
  249. data/lib/textus/write/enqueue.rb +0 -50
  250. data/lib/textus/write/key_delete.rb +0 -65
  251. data/lib/textus/write/key_mv.rb +0 -141
  252. data/lib/textus/write/propose.rb +0 -54
  253. data/lib/textus/write/put.rb +0 -74
  254. data/lib/textus/write/reject.rb +0 -68
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Gate
5
+ VERB_COMMAND = {
6
+ get: Textus::Command::Get,
7
+ put: Textus::Command::Put,
8
+ propose: Textus::Command::Propose,
9
+ key_delete: Textus::Command::KeyDelete,
10
+ key_mv: Textus::Command::KeyMv,
11
+ accept: Textus::Command::Accept,
12
+ reject: Textus::Command::Reject,
13
+ enqueue: Textus::Command::Enqueue,
14
+ list: Textus::Command::List,
15
+ where: Textus::Command::Where,
16
+ uid: Textus::Command::Uid,
17
+ blame: Textus::Command::Blame,
18
+ audit: Textus::Command::Audit,
19
+ deps: Textus::Command::Deps,
20
+ rdeps: Textus::Command::Rdeps,
21
+ pulse: Textus::Command::Pulse,
22
+ rule_explain: Textus::Command::RuleExplain,
23
+ rule_list: Textus::Command::RuleList,
24
+ rule_lint: Textus::Command::RuleLint,
25
+ published: Textus::Command::Published,
26
+ schema_show: Textus::Command::SchemaShow,
27
+ doctor: Textus::Command::Doctor,
28
+ boot: Textus::Command::Boot,
29
+ jobs: Textus::Command::Jobs,
30
+ data_mv: Textus::Command::DataMv,
31
+ key_mv_prefix: Textus::Command::KeyMvPrefix,
32
+ key_delete_prefix: Textus::Command::KeyDeletePrefix,
33
+ drain: Textus::Command::Drain,
34
+ }.freeze
35
+
36
+ ROUTES = {
37
+ Command::Get => [Textus::Action::Get],
38
+ Command::Put => [Textus::Action::Put],
39
+ Command::Propose => [Textus::Action::Propose],
40
+ Command::KeyDelete => [Textus::Action::KeyDelete],
41
+ Command::KeyMv => [Textus::Action::KeyMv],
42
+ Command::Accept => [Textus::Action::Accept],
43
+ Command::Reject => [Textus::Action::Reject],
44
+ Command::Enqueue => [Textus::Action::Enqueue],
45
+ Command::List => [Textus::Action::List],
46
+ Command::Where => [Textus::Action::Where],
47
+ Command::Uid => [Textus::Action::Uid],
48
+ Command::Blame => [Textus::Action::Blame],
49
+ Command::Audit => [Textus::Action::Audit],
50
+ Command::Deps => [Textus::Action::Deps],
51
+ Command::Rdeps => [Textus::Action::Rdeps],
52
+ Command::Pulse => [Textus::Action::Pulse],
53
+ Command::RuleExplain => [Textus::Action::RuleExplain],
54
+ Command::RuleList => [Textus::Action::RuleList],
55
+ Command::RuleLint => [Textus::Action::RuleLint],
56
+ Command::Published => [Textus::Action::Published],
57
+ Command::SchemaShow => [Textus::Action::SchemaEnvelope],
58
+ Command::Doctor => [Textus::Action::Doctor],
59
+ Command::Boot => [Textus::Action::Boot],
60
+ Command::Jobs => [Textus::Action::Jobs],
61
+ Command::DataMv => [Textus::Action::DataMv],
62
+ Command::KeyMvPrefix => [Textus::Action::KeyMvPrefix],
63
+ Command::KeyDeletePrefix => [Textus::Action::KeyDeletePrefix],
64
+ Command::Drain => [Textus::Action::Drain],
65
+ }.freeze
66
+
67
+ def initialize(container)
68
+ @container = container
69
+ end
70
+
71
+ def dispatch(cmd, correlation_id: nil)
72
+ cmd = normalize_propose_key(cmd, @container) if cmd.is_a?(Command::Propose)
73
+ action_classes = ROUTES.fetch(cmd.class) do
74
+ raise Textus::UsageError.new("unknown command: #{cmd.class}")
75
+ end
76
+
77
+ Gate::Auth.new(@container).check!(cmd)
78
+ call_obj = build_call(cmd, correlation_id: correlation_id)
79
+ results = action_classes.map { |klass| run_action(klass, cmd, @container, call_obj) }
80
+ results.length == 1 ? results.first : results
81
+ end
82
+
83
+ private
84
+
85
+ def normalize_propose_key(cmd, container)
86
+ return cmd if cmd.pending_key
87
+
88
+ zone = container.manifest.policy.propose_lane_for(cmd.role.to_s)
89
+ cmd.with(pending_key: zone ? "#{zone}.#{cmd.key}" : nil)
90
+ end
91
+
92
+ def run_action(klass, cmd, container, call_obj)
93
+ action = klass.new(**extract_kwargs(klass, cmd))
94
+ action.call(container:, call: call_obj)
95
+ end
96
+
97
+ def extract_kwargs(klass, cmd)
98
+ params = klass.instance_method(:initialize).parameters
99
+ accepts_keyrest = params.any? { |t, _| t == :keyrest }
100
+ param_set = params.to_set { |_t, n| n }
101
+ cmd.members.each_with_object({}) do |m, h|
102
+ next unless accepts_keyrest || param_set.include?(m)
103
+
104
+ val = cmd.public_send(m)
105
+ h[m] = val unless val.nil?
106
+ end
107
+ end
108
+
109
+ def build_call(cmd, correlation_id: nil)
110
+ dry_run = cmd.respond_to?(:dry_run) ? !cmd.dry_run.nil? : false
111
+ Textus::Call.build(role: cmd.role, dry_run:, correlation_id: correlation_id)
112
+ end
113
+ end
114
+ end
@@ -1,4 +1,4 @@
1
- # .textus/hooks/machine_intake.rb
1
+ # .textus/steps/fetch/machine_intake.rb
2
2
  # Scaffolded by `textus init` — CUSTOMIZE FREELY, or delete the feeds.machines
3
3
  # entry from manifest.yaml if you don't want it.
4
4
  # Feeds a per-host SNAPSHOT into feeds.machines.<host> on `textus drain`
@@ -6,40 +6,44 @@
6
6
  # `local` leaf scans THIS host; add ssh hosts with the cookbook recipe
7
7
  # (docs/cookbook/environment-scan.md). tracked:false → gitignored. Keep this an
8
8
  # ALLOWLIST of versions and counts — NEVER secrets, raw `env`, or package lists.
9
- Textus.hook do |reg|
10
- reg.on(:resolve_handler, :machines) do |config:, args:, **|
11
- machine = args[:leaf_segments].first or
12
- raise "machines intake needs a host leaf, e.g. the 'local' in feeds.machines.local"
13
- spec = (config["machines"] || {}).fetch(machine) { raise "unknown machine: #{machine}" }
14
- unless (spec["via"] || "local").to_s == "local"
15
- raise "machine #{machine}: only `via: local` is scaffolded — see " \
16
- "docs/cookbook/environment-scan.md for the SSH (remote) fan-out"
17
- end
9
+ module Textus
10
+ module Step
11
+ class MachineIntakeFetch < Fetch
12
+ def call(config:, args:, caps:, **)
13
+ machine = args[:leaf_segments].first or
14
+ raise "machines intake needs a host leaf, e.g. the 'local' in feeds.machines.local"
15
+ spec = (config["machines"] || {}).fetch(machine) { raise "unknown machine: #{machine}" }
16
+ unless (spec["via"] || "local").to_s == "local"
17
+ raise "machine #{machine}: only `via: local` is scaffolded — see " \
18
+ "docs/cookbook/environment-scan.md for the SSH (remote) fan-out"
19
+ end
18
20
 
19
- sh = ->(cmd) { `#{cmd}`.strip } # local shell-out, no network
20
- ver = ->(cmd) { o = `#{cmd} 2>/dev/null`.strip; o.empty? ? nil : o } # nil if tool absent
21
- count = ->(cmd) { n = `#{cmd} 2>/dev/null`.strip.lines.size; n.zero? ? nil : n }
22
- { content: {
23
- # git_* describe THIS repo on the control host — only meaningful for `local`.
24
- "git_head" => sh.call("git rev-parse --short HEAD 2>/dev/null"),
25
- "git_branch" => sh.call("git rev-parse --abbrev-ref HEAD 2>/dev/null"),
26
- "git_dirty" => !sh.call("git status --porcelain 2>/dev/null").empty?,
27
- "repo_root" => sh.call("git rev-parse --show-toplevel 2>/dev/null"),
28
- "captured_at" => Time.now.utc.iso8601,
29
- "os" => RbConfig::CONFIG["host_os"],
30
- "arch" => RbConfig::CONFIG["host_cpu"],
31
- "ruby_version" => RUBY_VERSION,
32
- "runtimes" => { # versions only; nil when not installed
33
- "node" => ver.call("node --version"),
34
- "python" => ver.call("python3 --version"),
35
- "go" => ver.call("go version"),
36
- },
37
- "packages" => { # COUNTS only — never the list (size/secrets)
38
- "brew" => count.call("brew list --formula"), # ~1-3s on macOS; runs only on fetch, amortized by the ttl rule
39
- "apt" => count.call("dpkg-query -f '.\n' -W"),
40
- },
41
- "textus_version" => Textus::VERSION,
42
- "protocol" => Textus::PROTOCOL,
43
- } }
21
+ sh = ->(cmd) { `#{cmd}`.strip } # local shell-out, no network
22
+ ver = ->(cmd) { o = `#{cmd} 2>/dev/null`.strip; o.empty? ? nil : o } # nil if tool absent
23
+ count = ->(cmd) { n = `#{cmd} 2>/dev/null`.strip.lines.size; n.zero? ? nil : n }
24
+ { content: {
25
+ # git_* describe THIS repo on the control host — only meaningful for `local`.
26
+ "git_head" => sh.call("git rev-parse --short HEAD 2>/dev/null"),
27
+ "git_branch" => sh.call("git rev-parse --abbrev-ref HEAD 2>/dev/null"),
28
+ "git_dirty" => !sh.call("git status --porcelain 2>/dev/null").empty?,
29
+ "repo_root" => sh.call("git rev-parse --show-toplevel 2>/dev/null"),
30
+ "captured_at" => Time.now.utc.iso8601,
31
+ "os" => RbConfig::CONFIG["host_os"],
32
+ "arch" => RbConfig::CONFIG["host_cpu"],
33
+ "ruby_version" => RUBY_VERSION,
34
+ "runtimes" => { # versions only; nil when not installed
35
+ "node" => ver.call("node --version"),
36
+ "python" => ver.call("python3 --version"),
37
+ "go" => ver.call("go version"),
38
+ },
39
+ "packages" => { # COUNTS only — never the list (size/secrets)
40
+ "brew" => count.call("brew list --formula"), # ~1-3s on macOS; runs only on fetch, amortized by the ttl rule
41
+ "apt" => count.call("dpkg-query -f '.\n' -W"),
42
+ },
43
+ "textus_version" => Textus::VERSION,
44
+ "protocol" => Textus::PROTOCOL,
45
+ } }
46
+ end
47
+ end
44
48
  end
45
49
  end
@@ -1,17 +1,21 @@
1
1
  # Reducer that reshapes the raw projection rows into the keys the
2
2
  # orientation.mustache template references. Without this, the template
3
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.") }
4
+ module Textus
5
+ module Step
6
+ class OrientationTransform < Transform
7
+ def call(rows:, config:, **)
8
+ project_row = rows.find { |r| r["_key"] == "knowledge.project" } || {}
9
+ runbook_rows = rows.select { |r| r["_key"]&.start_with?("knowledge.runbooks.") }
8
10
 
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
- }
11
+ {
12
+ "project" => {
13
+ "name" => project_row["name"],
14
+ "description" => project_row["description"]
15
+ },
16
+ "runbooks" => runbook_rows.map { |r| { "name" => r["name"], "description" => r["description"] } }
17
+ }
18
+ end
19
+ end
16
20
  end
17
21
  end
data/lib/textus/init.rb CHANGED
@@ -11,31 +11,31 @@ module Textus
11
11
  - { name: human, can: [author, propose] }
12
12
  - { name: agent, can: [propose, keep] }
13
13
  - { name: automation, can: [converge] }
14
- zones:
14
+ lanes:
15
15
  - { name: knowledge, kind: canon, desc: "the maintained source of truth (identity.* lives here)" }
16
16
  - { name: notebook, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
17
17
  - { name: proposals, kind: queue, desc: "changes awaiting your accept" }
18
18
  - { name: artifacts, kind: machine, desc: "machine-maintained: external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)" }
19
19
  entries:
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 }
20
+ - { key: knowledge.identity, path: data/knowledge/identity.md, lane: knowledge, schema: null, owner: human:self, kind: leaf }
21
+ - { key: knowledge.notes, path: data/knowledge/notes, lane: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
22
+ - { key: notebook.notes, path: data/notebook/notes, lane: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
23
+ - { key: proposals.notes, path: data/proposals/notes, lane: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
24
24
  # A per-host snapshot, refreshed from its declared intake by `textus drain` (scheduled, or on demand).
25
25
  # Nested so it grows to a fleet — add artifacts.feeds.machines.<host> leaves over SSH
26
26
  # (see docs/cookbook/environment-scan.md) without renaming. tracked:false →
27
27
  # gitignored (machine info can be sensitive/noisy) but still protocol-readable
28
28
  # via `textus get artifacts.feeds.machines.local`. Delete to opt out. (ADR 0043)
29
29
  - key: artifacts.feeds.machines
30
- path: artifacts/feeds/machines
31
- zone: artifacts
30
+ path: data/artifacts/feeds/machines
31
+ lane: artifacts
32
32
  format: yaml
33
33
  nested: true
34
34
  tracked: false
35
35
  kind: produced
36
36
  source:
37
- from: handler
38
- handler: machines
37
+ from: fetch
38
+ handler: machine-intake
39
39
  ttl: 1h # cadence on a long-running server
40
40
  config:
41
41
  machines:
@@ -43,57 +43,40 @@ module Textus
43
43
  rules: []
44
44
  YAML
45
45
 
46
- HOOKS_README = <<~MD
47
- # Hooks
46
+ STEPS_README = <<~MD
47
+ # Steps
48
48
 
49
- Drop one Ruby file per hook. All hooks register through one DSL.
50
- Files anywhere under `.textus/hooks/` (including subdirectories) are loaded at
51
- startup in alphabetical order by full path. Subdirectory names are organizational
52
- only — the registered event and name come from the DSL call, not the file path.
49
+ Drop one Ruby file per step. Steps are discovered by convention.
50
+ Files under `.textus/steps/<kind>/<name>.rb` are loaded at
51
+ startup and registered.
53
52
 
54
- ## DSL
53
+ ## Conventions
55
54
 
56
- ```ruby
57
- Textus.hook do |reg|
58
- reg.on(:resolve_handler, :my_source) do |config:, args:, **|
59
- { _meta: { "last_fetched_at" => Time.now.utc.iso8601 }, body: "…" }
60
- end
55
+ The directory name (`<kind>`) must be one of:
56
+ - `fetch`: Acquires data from outside the store.
57
+ - `transform`: Reshapes projection rows.
58
+ - `validate`: Validates data before writing.
59
+ - `observe`: Listens to store events.
61
60
 
62
- reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
63
- reg.on(:validate, :my_check) { |caps:, **| [] }
64
- reg.on(:entry_written, :my_listener, keys: ["knowledge.*"]) { |key:, envelope:, **| }
61
+ The filename (`<name>.rb`) defines the step name. The class defined
62
+ in the file must be a subclass of `Textus::Step::<Kind>` (e.g.
63
+ `Textus::Step::Fetch`) and be wrapped in the `Textus::Step` module.
65
64
 
66
- # Run a side-effect every time textus writes a file to your repo:
67
- reg.on(:entry_published, :notify) do |key:, target:, **|
68
- warn "wrote \#{target} (from \#{key})"
65
+ ## Example
66
+
67
+ ```ruby
68
+ module Textus
69
+ module Step
70
+ class MyFetch < Fetch
71
+ def call(config:, args:, caps:, **)
72
+ { content: { "foo" => "bar" } }
73
+ end
74
+ end
69
75
  end
70
76
  end
71
77
  ```
72
78
 
73
- The intake handler above is paired with a manifest entry whose
74
- `source:` block declares the handler and its refresh cadence
75
- (`ttl`). Age GC (drop/archive) lives in a top-level `retention:`
76
- rule, not on the entry:
77
-
78
- ```yaml
79
- entries:
80
- - key: artifacts.feeds.foo
81
- kind: produced
82
- path: artifacts/feeds/foo.md
83
- zone: artifacts
84
- source:
85
- from: handler
86
- handler: my_source
87
- ttl: 10m # refresh cadence for this intake
88
-
89
- rules:
90
- - match: artifacts.feeds.foo
91
- retention:
92
- ttl: 30d
93
- action: archive # drop | archive (age GC of stored rows)
94
- ```
95
-
96
- Events: :resolve_handler, :transform_rows, :validate (rpc — return value used)
79
+ Events: :fetch, :transform, :validate (rpc return value used)
97
80
  :entry_written, :entry_deleted, :entry_fetched, :entry_renamed,
98
81
  :entry_produced, :produce_failed,
99
82
  :proposal_accepted, :proposal_rejected,
@@ -104,50 +87,83 @@ module Textus
104
87
  MD
105
88
 
106
89
  AGENT_ENTRIES = <<~YAML.gsub(/^/, " ")
107
- # --with-agent profile: project facts + runbooks feed the orientation
108
- # projection below, which `textus drain` renders to CLAUDE.md/AGENTS.md.
109
- - { key: knowledge.project, path: knowledge/project.md, zone: knowledge, schema: project, owner: human:self, kind: leaf }
110
- - { key: knowledge.runbooks, path: knowledge/runbooks, zone: knowledge, schema: runbook, owner: human:self, nested: true, kind: nested }
90
+ - { key: knowledge.project, path: data/knowledge/project.md, lane: knowledge, schema: project, owner: human:self, kind: leaf }
91
+ - { key: knowledge.runbooks, path: data/knowledge/runbooks, lane: knowledge, schema: runbook, owner: human:self, nested: true, kind: nested }
111
92
  - key: artifacts.derived.orientation
112
- path: artifacts/derived/orientation.json
113
- zone: artifacts
93
+ path: data/artifacts/derived/orientation.json
94
+ lane: artifacts
114
95
  publish:
115
96
  - { to: CLAUDE.md, template: orientation.mustache, inject_boot: true }
116
97
  - { to: AGENTS.md, template: orientation.mustache, inject_boot: true }
117
98
  source:
118
- from: project
99
+ from: derive
119
100
  select:
120
101
  - knowledge.project
121
102
  - knowledge.runbooks
122
- transform: orientation_reducer
103
+ transform: orientation
123
104
  kind: produced
124
105
  YAML
125
106
 
126
107
  def self.run(target_root, with_agent: false)
108
+ check_target!(target_root)
109
+ scaffold_dir = File.expand_path("init/templates", __dir__)
110
+ create_directories(target_root)
111
+ write_steps_readme(target_root, scaffold_dir)
112
+ write_manifest(target_root, with_agent:)
113
+ mcp_status = scaffold_agent(target_root, scaffold_dir, with_agent:)
114
+ setup_state_dirs(target_root)
115
+ write_gitignore(target_root)
116
+ build_result(target_root, with_agent:, mcp_status:)
117
+ end
118
+
119
+ def self.check_target!(target_root)
127
120
  raise UsageError.new(".textus/ already exists at #{target_root}") if File.directory?(target_root)
121
+ end
128
122
 
123
+ def self.create_directories(target_root)
129
124
  FileUtils.mkdir_p(File.join(target_root, "schemas"))
130
125
  FileUtils.mkdir_p(File.join(target_root, "templates"))
131
- FileUtils.mkdir_p(File.join(target_root, "hooks"))
126
+ FileUtils.mkdir_p(File.join(target_root, "steps/fetch"))
127
+ FileUtils.mkdir_p(File.join(target_root, "steps/transform"))
128
+ FileUtils.mkdir_p(File.join(target_root, "steps/validate"))
129
+ FileUtils.mkdir_p(File.join(target_root, "steps/observe"))
132
130
  ZONES.each do |z|
133
- dir = File.join(target_root, "zones", z)
131
+ dir = File.join(target_root, "data", z)
134
132
  FileUtils.mkdir_p(dir)
135
133
  File.write(File.join(dir, ".gitkeep"), "")
136
134
  end
137
- File.write(File.join(target_root, "hooks", "README.md"), HOOKS_README)
138
- scaffold_dir = File.expand_path("init/templates", __dir__)
139
- File.write(File.join(target_root, "hooks", "machine_intake.rb"),
140
- File.read(File.join(scaffold_dir, "machine_intake.rb")))
135
+ end
136
+
137
+ def self.write_steps_readme(target_root, scaffold_dir)
138
+ File.write(File.join(target_root, "steps/README.md"), STEPS_README)
139
+ File.write(
140
+ File.join(target_root, "steps/fetch/machine-intake.rb"),
141
+ File.read(File.join(scaffold_dir, "machine_intake.rb")),
142
+ )
143
+ end
144
+
145
+ def self.write_manifest(target_root, with_agent:)
141
146
  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
147
+ end
148
+
149
+ def self.scaffold_agent(target_root, scaffold_dir, with_agent:)
150
+ return nil unless with_agent
151
+
152
+ scaffold_agent_profile(target_root, scaffold_dir)
153
+ write_mcp_config(target_root, scaffold_dir)
154
+ end
155
+
156
+ def self.setup_state_dirs(target_root)
147
157
  FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
148
158
  FileUtils.mkdir_p(Textus::Layout.state(target_root))
149
159
  FileUtils.mkdir_p(Textus::Layout.locks(target_root))
160
+ end
161
+
162
+ def self.write_gitignore(target_root)
150
163
  File.write(File.join(target_root, ".gitignore"), derived_gitignore(target_root))
164
+ end
165
+
166
+ def self.build_result(target_root, with_agent:, mcp_status:)
151
167
  result = { "protocol" => PROTOCOL, "initialized" => target_root, "profile" => with_agent ? "agent" : "default" }
152
168
  result["mcp_config"] = mcp_status if with_agent
153
169
  result
@@ -168,7 +184,7 @@ module Textus
168
184
  "project.schema.yaml" => File.join("schemas", "project.yaml"),
169
185
  "runbook.schema.yaml" => File.join("schemas", "runbook.yaml"),
170
186
  "orientation.mustache" => File.join("templates", "orientation.mustache"),
171
- "orientation_reducer.rb" => File.join("hooks", "orientation_reducer.rb"),
187
+ "orientation_reducer.rb" => File.join("steps/transform", "orientation.rb"),
172
188
  }.each do |src, dest|
173
189
  File.write(File.join(target_root, dest), File.read(File.join(scaffold_dir, src)))
174
190
  end
@@ -193,8 +209,9 @@ module Textus
193
209
  manifest = Textus::Manifest.load(target_root)
194
210
  root = Pathname.new(target_root)
195
211
  untracked = manifest.data.entries.reject(&:tracked?).map do |e|
196
- if e.nested? # a whole subtree of leaf files (artifacts.feeds.machines.* → zones/artifacts/feeds/machines/)
197
- "#{File.join("zones", e.path)}/"
212
+ if e.nested? # a whole subtree of leaf files (artifacts.feeds.machines.* → data/artifacts/feeds/machines/)
213
+ rel = e.path.start_with?("data/") ? e.path : File.join("data", e.path)
214
+ "#{rel}/"
198
215
  else
199
216
  Pathname.new(Textus::Key::Path.resolve(manifest.data, e)).relative_path_from(root).to_s
200
217
  end
@@ -10,12 +10,19 @@ module Textus
10
10
  # `manifest.data`.
11
11
  def self.resolve(data, mentry)
12
12
  primary_ext = Entry.for_format(mentry.format).extensions.first
13
+ rel_path = normalize_relative_path(mentry.path)
13
14
  if File.extname(mentry.path) == ""
14
- File.join(data.root, "zones", mentry.path + primary_ext)
15
+ File.join(data.root, rel_path + primary_ext)
15
16
  else
16
- File.join(data.root, "zones", mentry.path)
17
+ File.join(data.root, rel_path)
17
18
  end
18
19
  end
20
+
21
+ def self.normalize_relative_path(path)
22
+ return path if path.start_with?("data/")
23
+
24
+ File.join("data", path)
25
+ end
19
26
  end
20
27
  end
21
28
  end
data/lib/textus/layout.rb CHANGED
@@ -4,6 +4,15 @@ module Textus
4
4
  # tracked/disposable boundary is a directory boundary. ADR 0038.
5
5
  module Layout
6
6
  RUN = ".run"
7
+ DATA = "data"
8
+
9
+ def self.data(root)
10
+ File.join(root, DATA)
11
+ end
12
+
13
+ def self.data_lane(root, lane_name)
14
+ File.join(data(root), lane_name)
15
+ end
7
16
 
8
17
  def self.run(root)
9
18
  File.join(root, RUN)
@@ -25,6 +34,10 @@ module Textus
25
34
  File.join(run(root), "build.lock")
26
35
  end
27
36
 
37
+ def self.watcher_lock(root)
38
+ File.join(run(root), "watcher.lock")
39
+ end
40
+
28
41
  def self.queue(root)
29
42
  File.join(run(root), "queue")
30
43
  end
@@ -5,15 +5,15 @@ module Textus
5
5
  class Manifest
6
6
  # Immutable, parsed view of a manifest YAML document.
7
7
  #
8
- # Holds raw structural data (zones, entries, audit_config, role_caps)
9
- # but no behaviour beyond accessors. Behaviour (zone authority, key
8
+ # Holds raw structural data (lanes, entries, audit_config, role_caps)
9
+ # but no behaviour beyond accessors. Behaviour (lane authority, key
10
10
  # resolution, rules) lives on Manifest::Policy / Resolver / Rules.
11
11
  class Data
12
12
  AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
13
13
  WORKER_DEFAULTS = { pool: 4, poll: 5, lease_ttl: 60, max_attempts: 3 }.freeze
14
14
 
15
- attr_reader :raw, :root, :entries, :declared_zone_kinds,
16
- :zone_descs, :zone_owners,
15
+ attr_reader :raw, :root, :entries, :declared_lane_kinds,
16
+ :lane_descs, :lane_owners,
17
17
  :audit_config, :worker_config, :role_caps, :policy
18
18
 
19
19
  def self.validate_key!(key)
@@ -27,7 +27,7 @@ module Textus
27
27
  def validate_key!(key) = self.class.validate_key!(key)
28
28
 
29
29
  def self.parse(raw, root:)
30
- raise BadFrontmatter.new(File.join(root.to_s, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
30
+ raise BadFrontmatter.new(File.join(root.to_s, "manifest.yaml"), "manifest must declare lanes:") if Array(raw["lanes"]).empty?
31
31
 
32
32
  Schema.validate!(raw)
33
33
  new(raw: raw, root: root)
@@ -36,17 +36,17 @@ module Textus
36
36
  def initialize(raw:, root:)
37
37
  @raw = raw
38
38
  @root = root
39
- # Write authority is derived from capabilities × zone-kind (ADR 0030),
40
- # not a per-zone writer list. "Which zones are declared" lives in the
41
- # one kind-keyed map below (declared_zone_kinds); membership checks by
42
- # read-side callers (boot, maintenance/zone_mv) use its keyset (ADR 0034).
43
- @declared_zone_kinds = Array(raw["zones"]).to_h do |z|
39
+ # Write authority is derived from capabilities × lane-kind (ADR 0030),
40
+ # not a per-lane writer list. "Which lanes are declared" lives in the
41
+ # one kind-keyed map below (declared_lane_kinds); membership checks by
42
+ # read-side callers (boot, maintenance/data_mv) use its keyset (ADR 0034).
43
+ @declared_lane_kinds = Array(raw["lanes"]).to_h do |z|
44
44
  [z["name"], z["kind"]&.to_sym]
45
45
  end
46
- @zone_descs = Array(raw["zones"]).to_h { |z| [z["name"], z["desc"]] }
47
- # Only zones that actually declare an owner — keep nil-tombstones out so a
48
- # future `zone_owners.key?(name)` means "owner declared", not "zone exists".
49
- @zone_owners = Array(raw["zones"]).to_h { |z| [z["name"], z["owner"]] }.compact
46
+ @lane_descs = Array(raw["lanes"]).to_h { |z| [z["name"], z["desc"]] }
47
+ # Only lanes that actually declare an owner — keep nil-tombstones out so a
48
+ # future `lane_owners.key?(name)` means "owner declared", not "lane exists".
49
+ @lane_owners = Array(raw["lanes"]).to_h { |z| [z["name"], z["owner"]] }.compact
50
50
  @audit_config = build_audit_config(raw)
51
51
  @worker_config = build_worker_config(raw)
52
52
  @role_caps = Capabilities.resolve(raw["roles"])