textus 0.51.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 (250) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +74 -66
  4. data/SPEC.md +100 -223
  5. data/docs/architecture/README.md +91 -80
  6. data/docs/reference/conventions.md +8 -8
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +53 -0
  9. data/lib/textus/action/audit.rb +133 -0
  10. data/lib/textus/action/base.rb +42 -0
  11. data/lib/textus/{read → action}/blame.rb +30 -22
  12. data/lib/textus/action/boot.rb +26 -0
  13. data/lib/textus/action/data_mv.rb +71 -0
  14. data/lib/textus/action/deps.rb +48 -0
  15. data/lib/textus/action/doctor.rb +26 -0
  16. data/lib/textus/action/drain.rb +41 -0
  17. data/lib/textus/action/enqueue.rb +55 -0
  18. data/lib/textus/action/get.rb +80 -0
  19. data/lib/textus/action/jobs.rb +38 -0
  20. data/lib/textus/action/key_delete.rb +46 -0
  21. data/lib/textus/action/key_delete_prefix.rb +46 -0
  22. data/lib/textus/action/key_mv.rb +143 -0
  23. data/lib/textus/action/key_mv_prefix.rb +59 -0
  24. data/lib/textus/action/list.rb +44 -0
  25. data/lib/textus/action/propose.rb +54 -0
  26. data/lib/textus/action/published.rb +26 -0
  27. data/lib/textus/action/pulse/scanner.rb +118 -0
  28. data/lib/textus/action/pulse.rb +87 -0
  29. data/lib/textus/action/put.rb +63 -0
  30. data/lib/textus/action/rdeps.rb +49 -0
  31. data/lib/textus/action/reject.rb +49 -0
  32. data/lib/textus/action/rule_explain.rb +95 -0
  33. data/lib/textus/action/rule_lint.rb +70 -0
  34. data/lib/textus/action/rule_list.rb +46 -0
  35. data/lib/textus/action/schema_envelope.rb +31 -0
  36. data/lib/textus/action/uid.rb +35 -0
  37. data/lib/textus/action/where.rb +38 -0
  38. data/lib/textus/action/write_verb.rb +58 -0
  39. data/lib/textus/background/job/base.rb +27 -0
  40. data/lib/textus/background/job/materialize.rb +31 -0
  41. data/lib/textus/background/job/refresh.rb +22 -0
  42. data/lib/textus/background/job/sweep.rb +31 -0
  43. data/lib/textus/background/job.rb +19 -0
  44. data/lib/textus/background/plan.rb +9 -0
  45. data/lib/textus/background/planner/plan.rb +113 -0
  46. data/lib/textus/background/retention/apply.rb +50 -0
  47. data/lib/textus/background/worker.rb +67 -0
  48. data/lib/textus/boot.rb +59 -49
  49. data/lib/textus/command.rb +36 -0
  50. data/lib/textus/container.rb +1 -1
  51. data/lib/textus/{domain → core}/duration.rb +1 -1
  52. data/lib/textus/{domain → core}/freshness/evaluator.rb +13 -13
  53. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  54. data/lib/textus/{domain → core}/freshness.rb +2 -2
  55. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  56. data/lib/textus/{domain → core}/retention.rb +2 -2
  57. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  58. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  59. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  60. data/lib/textus/doctor/check/hooks.rb +11 -18
  61. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  62. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  63. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  64. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  65. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  66. data/lib/textus/doctor/check/sentinels.rb +2 -2
  67. data/lib/textus/doctor/check.rb +12 -9
  68. data/lib/textus/{read → doctor}/validator.rb +22 -13
  69. data/lib/textus/doctor.rb +6 -6
  70. data/lib/textus/envelope/io/writer.rb +65 -36
  71. data/lib/textus/envelope.rb +5 -3
  72. data/lib/textus/errors.rb +19 -11
  73. data/lib/textus/events.rb +21 -0
  74. data/lib/textus/gate/auth.rb +181 -0
  75. data/lib/textus/gate.rb +114 -0
  76. data/lib/textus/init/templates/machine_intake.rb +40 -36
  77. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  78. data/lib/textus/init.rb +93 -76
  79. data/lib/textus/key/path.rb +9 -2
  80. data/lib/textus/layout.rb +21 -0
  81. data/lib/textus/manifest/capabilities.rb +1 -1
  82. data/lib/textus/manifest/data.rb +30 -15
  83. data/lib/textus/manifest/entry/base.rb +15 -11
  84. data/lib/textus/manifest/entry/parser.rb +6 -6
  85. data/lib/textus/manifest/entry/produced.rb +3 -2
  86. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  87. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  88. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  89. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +4 -4
  94. data/lib/textus/{domain → manifest}/policy/source.rb +27 -28
  95. data/lib/textus/manifest/policy.rb +36 -48
  96. data/lib/textus/manifest/resolver.rb +3 -2
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +18 -12
  99. data/lib/textus/manifest/schema/validator.rb +27 -25
  100. data/lib/textus/manifest/schema/vocabulary.rb +3 -3
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/{produce → pipeline}/acquire/handler.rb +3 -3
  104. data/lib/textus/{produce → pipeline}/acquire/intake.rb +23 -21
  105. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  106. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  107. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  108. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  109. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  110. data/lib/textus/{produce → pipeline}/engine.rb +17 -63
  111. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  112. data/lib/textus/ports/audit_log.rb +31 -5
  113. data/lib/textus/ports/audit_subscriber.rb +4 -4
  114. data/lib/textus/ports/build_lock.rb +1 -1
  115. data/lib/textus/ports/queue/job.rb +65 -0
  116. data/lib/textus/ports/queue.rb +130 -0
  117. data/lib/textus/ports/sentinel_store.rb +2 -2
  118. data/lib/textus/ports/watcher_lock.rb +48 -0
  119. data/lib/textus/projection.rb +8 -8
  120. data/lib/textus/role.rb +1 -1
  121. data/lib/textus/schema/tools.rb +4 -3
  122. data/lib/textus/session.rb +6 -3
  123. data/lib/textus/step/base.rb +35 -0
  124. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  125. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  126. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  127. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  128. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  129. data/lib/textus/step/builtin.rb +22 -0
  130. data/lib/textus/{hooks → step}/catalog.rb +3 -4
  131. data/lib/textus/{hooks → step}/context.rb +15 -13
  132. data/lib/textus/step/discovery.rb +24 -0
  133. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  134. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  135. data/lib/textus/step/fetch.rb +13 -0
  136. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  137. data/lib/textus/step/loader.rb +108 -0
  138. data/lib/textus/step/observe.rb +31 -0
  139. data/lib/textus/step/registry_store.rb +66 -0
  140. data/lib/textus/{hooks → step}/signature.rb +1 -1
  141. data/lib/textus/step/transform.rb +12 -0
  142. data/lib/textus/step/validate.rb +11 -0
  143. data/lib/textus/step.rb +10 -0
  144. data/lib/textus/store.rb +17 -15
  145. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  146. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  147. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  148. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  149. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  150. data/lib/textus/surfaces/cli/group.rb +50 -0
  151. data/lib/textus/surfaces/cli/runner.rb +236 -0
  152. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  153. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  155. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  156. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  157. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  158. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  159. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  160. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  161. data/lib/textus/surfaces/cli/verb.rb +111 -0
  162. data/lib/textus/surfaces/cli.rb +148 -0
  163. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  164. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  165. data/lib/textus/surfaces/mcp/server.rb +145 -0
  166. data/lib/textus/surfaces/mcp/session.rb +9 -0
  167. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  168. data/lib/textus/surfaces/mcp.rb +8 -0
  169. data/lib/textus/surfaces/role_scope.rb +38 -0
  170. data/lib/textus/surfaces/watcher.rb +38 -0
  171. data/lib/textus/version.rb +1 -1
  172. data/lib/textus.rb +64 -22
  173. metadata +133 -107
  174. data/lib/textus/cli/group/hook.rb +0 -9
  175. data/lib/textus/cli/group/key.rb +0 -9
  176. data/lib/textus/cli/group/mcp.rb +0 -9
  177. data/lib/textus/cli/group/rule.rb +0 -9
  178. data/lib/textus/cli/group/schema.rb +0 -9
  179. data/lib/textus/cli/group/zone.rb +0 -9
  180. data/lib/textus/cli/group.rb +0 -48
  181. data/lib/textus/cli/runner.rb +0 -193
  182. data/lib/textus/cli/verb/doctor.rb +0 -17
  183. data/lib/textus/cli/verb/get.rb +0 -18
  184. data/lib/textus/cli/verb/hook_run.rb +0 -48
  185. data/lib/textus/cli/verb/hooks.rb +0 -50
  186. data/lib/textus/cli/verb/init.rb +0 -18
  187. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  188. data/lib/textus/cli/verb/put.rb +0 -30
  189. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  190. data/lib/textus/cli/verb/schema_init.rb +0 -19
  191. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  192. data/lib/textus/cli/verb.rb +0 -116
  193. data/lib/textus/cli.rb +0 -138
  194. data/lib/textus/dispatcher.rb +0 -52
  195. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  196. data/lib/textus/domain/action.rb +0 -9
  197. data/lib/textus/domain/permission.rb +0 -7
  198. data/lib/textus/domain/policy/base_guards.rb +0 -25
  199. data/lib/textus/domain/policy/evaluation.rb +0 -15
  200. data/lib/textus/domain/policy/guard.rb +0 -35
  201. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  202. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  203. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  204. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  205. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  206. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  207. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  208. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  209. data/lib/textus/hooks/builtin.rb +0 -70
  210. data/lib/textus/hooks/loader.rb +0 -54
  211. data/lib/textus/hooks/rpc_registry.rb +0 -43
  212. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  213. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  214. data/lib/textus/maintenance/reconcile.rb +0 -160
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/zone_mv.rb +0 -64
  217. data/lib/textus/maintenance.rb +0 -15
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -69
  226. data/lib/textus/produce/events.rb +0 -36
  227. data/lib/textus/read/audit.rb +0 -130
  228. data/lib/textus/read/boot.rb +0 -26
  229. data/lib/textus/read/capabilities.rb +0 -70
  230. data/lib/textus/read/deps.rb +0 -38
  231. data/lib/textus/read/doctor.rb +0 -27
  232. data/lib/textus/read/freshness.rb +0 -152
  233. data/lib/textus/read/get.rb +0 -73
  234. data/lib/textus/read/list.rb +0 -24
  235. data/lib/textus/read/published.rb +0 -22
  236. data/lib/textus/read/pulse.rb +0 -98
  237. data/lib/textus/read/rdeps.rb +0 -39
  238. data/lib/textus/read/rule_explain.rb +0 -96
  239. data/lib/textus/read/rule_list.rb +0 -54
  240. data/lib/textus/read/schema_envelope.rb +0 -25
  241. data/lib/textus/read/uid.rb +0 -29
  242. data/lib/textus/read/validate_all.rb +0 -36
  243. data/lib/textus/read/where.rb +0 -24
  244. data/lib/textus/role_scope.rb +0 -78
  245. data/lib/textus/write/accept.rb +0 -58
  246. data/lib/textus/write/key_delete.rb +0 -65
  247. data/lib/textus/write/key_mv.rb +0 -141
  248. data/lib/textus/write/propose.rb +0 -54
  249. data/lib/textus/write/put.rb +0 -74
  250. data/lib/textus/write/reject.rb +0 -68
data/lib/textus/init.rb CHANGED
@@ -10,32 +10,32 @@ module Textus
10
10
  roles:
11
11
  - { name: human, can: [author, propose] }
12
12
  - { name: agent, can: [propose, keep] }
13
- - { name: automation, can: [reconcile] }
14
- zones:
13
+ - { name: automation, can: [converge] }
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 }
24
- # A per-host snapshot, refreshed from its declared intake by `textus reconcile` (scheduled, or on demand).
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
+ # 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,59 +43,42 @@ 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
- :entry_produced, :produce_failed, :reconcile_failed,
81
+ :entry_produced, :produce_failed,
99
82
  :proposal_accepted, :proposal_rejected,
100
83
  :entry_published, :store_loaded, :session_opened,
101
84
  :entry_fetch_started, :entry_fetch_failed (pub-sub — return discarded)
@@ -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 reconcile` 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,18 @@ 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
+
41
+ def self.queue(root)
42
+ File.join(run(root), "queue")
43
+ end
44
+
45
+ def self.queue_state(root, state)
46
+ File.join(queue(root), state.to_s)
47
+ end
48
+
28
49
  def self.audit_dir(root)
29
50
  File.join(run(root), "audit")
30
51
  end
@@ -13,7 +13,7 @@ module Textus
13
13
  DEFAULT_MAPPING = {
14
14
  Textus::Role::HUMAN => %w[author propose].freeze,
15
15
  Textus::Role::AGENT => %w[propose].freeze,
16
- Textus::Role::AUTOMATION => %w[reconcile].freeze,
16
+ Textus::Role::AUTOMATION => %w[converge].freeze,
17
17
  }.freeze
18
18
 
19
19
  # Returns { role_name => [verbs] }. When `roles:` is declared we use
@@ -5,15 +5,16 @@ 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
+ WORKER_DEFAULTS = { pool: 4, poll: 5, lease_ttl: 60, max_attempts: 3 }.freeze
13
14
 
14
- attr_reader :raw, :root, :entries, :declared_zone_kinds,
15
- :zone_descs, :zone_owners,
16
- :audit_config, :role_caps, :policy
15
+ attr_reader :raw, :root, :entries, :declared_lane_kinds,
16
+ :lane_descs, :lane_owners,
17
+ :audit_config, :worker_config, :role_caps, :policy
17
18
 
18
19
  def self.validate_key!(key)
19
20
  raise UsageError.new("empty key") if key.nil? || key.empty?
@@ -26,7 +27,7 @@ module Textus
26
27
  def validate_key!(key) = self.class.validate_key!(key)
27
28
 
28
29
  def self.parse(raw, root:)
29
- 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?
30
31
 
31
32
  Schema.validate!(raw)
32
33
  new(raw: raw, root: root)
@@ -35,18 +36,19 @@ module Textus
35
36
  def initialize(raw:, root:)
36
37
  @raw = raw
37
38
  @root = root
38
- # Write authority is derived from capabilities × zone-kind (ADR 0030),
39
- # not a per-zone writer list. "Which zones are declared" lives in the
40
- # one kind-keyed map below (declared_zone_kinds); membership checks by
41
- # read-side callers (boot, maintenance/zone_mv) use its keyset (ADR 0034).
42
- @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|
43
44
  [z["name"], z["kind"]&.to_sym]
44
45
  end
45
- @zone_descs = Array(raw["zones"]).to_h { |z| [z["name"], z["desc"]] }
46
- # Only zones that actually declare an owner — keep nil-tombstones out so a
47
- # future `zone_owners.key?(name)` means "owner declared", not "zone exists".
48
- @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
49
50
  @audit_config = build_audit_config(raw)
51
+ @worker_config = build_worker_config(raw)
50
52
  @role_caps = Capabilities.resolve(raw["roles"])
51
53
  # Policy is constructed before entries because Entry validators
52
54
  # use the entry's own `derived?` and similar helpers that call into
@@ -67,6 +69,19 @@ module Textus
67
69
  }.freeze
68
70
  end
69
71
 
72
+ # Worker/queue tunables (ADR: job-queue execution model). All optional;
73
+ # the daemon (serve) and batch drain read these, falling back to defaults
74
+ # so a manifest with no `worker:` block runs the queue out of the box.
75
+ def build_worker_config(raw)
76
+ w = raw["worker"] || {}
77
+ {
78
+ pool: w["pool"] || WORKER_DEFAULTS[:pool],
79
+ poll: w["poll"] || WORKER_DEFAULTS[:poll],
80
+ lease_ttl: w["lease_ttl"] || WORKER_DEFAULTS[:lease_ttl],
81
+ max_attempts: w["max_attempts"] || WORKER_DEFAULTS[:max_attempts],
82
+ }.freeze
83
+ end
84
+
70
85
  def build_entries(raw)
71
86
  Array(raw["entries"]).map do |e|
72
87
  entry = Manifest::Entry::Parser.call(e)
@@ -2,14 +2,14 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  class Base < Entry
5
- attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :publish_targets
5
+ attr_reader :raw, :key, :path, :lane, :schema, :owner, :format, :publish_targets
6
6
 
7
7
  # rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
8
- def initialize(raw:, key:, path:, zone:, schema:, owner:, format:, publish_targets: [])
8
+ def initialize(raw:, key:, path:, lane:, schema:, owner:, format:, publish_targets: [])
9
9
  @raw = raw
10
10
  @key = key
11
11
  @path = path
12
- @zone = zone
12
+ @lane = lane
13
13
  @schema = schema
14
14
  @owner = owner
15
15
  @format = format
@@ -17,13 +17,14 @@ module Textus
17
17
  end
18
18
  # rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
19
19
 
20
- def zone_writers(policy)
21
- policy.zone_writers(@zone)
20
+ def lane_writers(policy)
21
+ verb = policy.verb_for_lane(@lane)
22
+ policy.roles_with_capability(verb)
22
23
  rescue UsageError => e
23
24
  raise UsageError.new("entry '#{@key}': #{e.message}")
24
25
  end
25
26
 
26
- def in_proposal_zone?(policy) = policy.queue_zone?(@zone)
27
+ def in_proposal_lane?(policy) = policy.queue_lane?(@lane)
27
28
 
28
29
  def nested? = false
29
30
  def derived? = false
@@ -36,6 +37,9 @@ module Textus
36
37
  def external? = false
37
38
  def projection? = false
38
39
 
40
+ alias zone lane
41
+ alias in_proposal_zone? in_proposal_lane?
42
+
39
43
  # Whether git should track this entry's file. Default true; an entry
40
44
  # marked `tracked: false` in the manifest stays protocol-readable but is
41
45
  # listed in the generated `.gitignore` (ADR 0043). Cross-cutting, so it
@@ -60,19 +64,19 @@ module Textus
60
64
  # Minimal context object passed into entry `publish_via` hooks.
61
65
  # Everything beyond the three primitives is derived. Data.define
62
66
  # instances are frozen, so we recompute per-call rather than
63
- # memoizing — RoleScope/Hooks::Context construction is cheap.
67
+ # memoizing — RoleScope/Step::Context construction is cheap.
64
68
  PublishContext = ::Data.define(:container, :call, :reader) do
65
69
  def manifest = container.manifest
66
70
  def root = container.root
67
71
  def repo_root = File.dirname(container.root)
68
- def events = container.events
72
+ def steps = container.steps
69
73
 
70
74
  def hook_context
71
- Textus::Hooks::Context.new(scope: scope_for_hooks)
75
+ Textus::Step::Context.new(scope: scope_for_hooks)
72
76
  end
73
77
 
74
78
  def emit(event, **payload)
75
- events.publish(event, ctx: hook_context, **payload)
79
+ steps.publish(event, ctx: hook_context, **payload)
76
80
  end
77
81
 
78
82
  # Read a named template from the store's templates/ directory.
@@ -91,7 +95,7 @@ module Textus
91
95
  private
92
96
 
93
97
  def scope_for_hooks
94
- Textus::RoleScope.new(
98
+ Textus::Surfaces::RoleScope.new(
95
99
  container: container, role: call.role, dry_run: call.dry_run,
96
100
  )
97
101
  end
@@ -5,7 +5,7 @@ module Textus
5
5
  def self.call(raw)
6
6
  key = raw["key"] or raise UsageError.new("manifest entry missing key")
7
7
  path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
8
- zone = raw["zone"] or raise UsageError.new("manifest entry '#{key}' missing zone")
8
+ lane = raw["lane"] or raise UsageError.new("manifest entry '#{key}' missing lane")
9
9
 
10
10
  raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (#{Entry::REGISTRY.keys.join("|")})")
11
11
  kind = raw_kind.to_sym
@@ -19,7 +19,7 @@ module Textus
19
19
 
20
20
  common = {
21
21
  raw: raw,
22
- key: key, path: path, zone: zone,
22
+ key: key, path: path, lane: lane,
23
23
  schema: raw["schema"], owner: raw["owner"],
24
24
  format: format,
25
25
  publish_targets: publish_targets(raw)
@@ -31,12 +31,12 @@ module Textus
31
31
  end
32
32
 
33
33
  # ADR 0093: an entry's production block is the unified `source:`. Returns a
34
- # Domain::Policy::Source; kind (intake/derived) is read from source.from.
34
+ # Manifest::Policy::Source; kind (intake/derived) is read from source.from.
35
35
  def self.parse_source(raw, key)
36
36
  block = raw["source"] or
37
- raise BadManifest.new("entry '#{key}' requires a source: { from: project|handler|command, ... }")
37
+ raise BadManifest.new("entry '#{key}' requires a source: { from: derive|fetch|external, ... }")
38
38
 
39
- Textus::Domain::Policy::Source.new(block)
39
+ Textus::Manifest::Policy::Source.new(block)
40
40
  end
41
41
 
42
42
  # ADR 0094: `publish:` is a LIST of target objects — to-targets
@@ -52,7 +52,7 @@ module Textus
52
52
  "[{to:, template:?} | {tree:}] (ADR 0094); the `publish: { … }` map form was retired",
53
53
  )
54
54
  end
55
- block.map { |t| Textus::Domain::Policy::PublishTarget.new(t) }
55
+ block.map { |t| Textus::Manifest::Policy::PublishTarget.new(t) }
56
56
  end
57
57
 
58
58
  def self.resolve_format(raw, path)
@@ -17,6 +17,8 @@ module Textus
17
17
  def derived? = @source.kind == :derived
18
18
  def external? = @source.external?
19
19
  def projection? = @source.projection?
20
+ def fetch? = @source.fetch?
21
+ def derive? = @source.derive?
20
22
  def nested? = !!@raw["nested"]
21
23
  def handler = @source.handler
22
24
  def config = @source.config
@@ -33,11 +35,10 @@ module Textus
33
35
  def publish_via(pctx, prefix: nil)
34
36
  built = false
35
37
  if projection?
36
- Textus::Produce::Acquire::Projection.new(container: pctx.container, call: pctx.call).run(self)
38
+ Textus::Pipeline::Acquire::Projection.new(container: pctx.container, call: pctx.call).run(self)
37
39
  built = true
38
40
  pctx.emit(:entry_produced, key: @key, envelope: pctx.reader.call(@key), sources: Array(@source.select).compact)
39
41
  end
40
-
41
42
  emitted = publish_mode.publish(pctx, prefix: prefix)
42
43
  return emitted if emitted
43
44
  return nil unless built
@@ -36,7 +36,7 @@ module Textus
36
36
 
37
37
  # Store-side directory this entry's tree lives under.
38
38
  def store_base(pctx)
39
- File.join(pctx.root, "zones", entry.path)
39
+ File.join(pctx.root, Textus::Key::Path.normalize_relative_path(entry.path))
40
40
  end
41
41
  end
42
42
  end
@@ -16,11 +16,12 @@ module Textus
16
16
 
17
17
  def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument,Metrics/AbcSize
18
18
  targets = entry.publish_targets.select(&:to_target?)
19
+
19
20
  return nil if targets.empty?
20
21
 
21
22
  data_path = pctx.manifest.resolver.resolve(entry.key).path
22
23
  envelope = pctx.reader.call(entry.key)
23
- renderer = Textus::Produce::Render.new(template_loader: ->(n) { pctx.read_template(n) })
24
+ renderer = Textus::Pipeline::Render.new(template_loader: ->(n) { pctx.read_template(n) })
24
25
  content = nil # parsed lazily; the data's `content` (always _meta-free)
25
26
 
26
27
  targets.each do |t|
@@ -4,7 +4,7 @@ module Textus
4
4
  module Validators
5
5
  module Events
6
6
  def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
7
- pubsub_events = Textus::Hooks::Catalog::PUBSUB.keys
7
+ pubsub_events = Textus::Step::Catalog::PUBSUB.keys
8
8
  events = entry.events
9
9
  events.each_key do |evt|
10
10
  next if pubsub_events.include?(evt.to_sym)
@@ -1,14 +1,14 @@
1
1
  module Textus
2
- module Domain
3
- module Policy
4
- class HandlerAllowlist
2
+ class Manifest
3
+ class Policy
4
+ class HandlerPermit
5
5
  attr_reader :handlers
6
6
 
7
7
  def initialize(handlers:)
8
8
  @handlers = Array(handlers).map(&:to_s).freeze
9
9
  end
10
10
 
11
- def allows?(handler)
11
+ def permits?(handler)
12
12
  @handlers.include?(handler.to_s)
13
13
  end
14
14
  end
@@ -1,6 +1,6 @@
1
1
  module Textus
2
- module Domain
3
- module Policy
2
+ class Manifest
3
+ class Policy
4
4
  module Matcher
5
5
  module_function
6
6
 
@@ -1,6 +1,6 @@
1
1
  module Textus
2
- module Domain
3
- module Policy
2
+ class Manifest
3
+ class Policy
4
4
  # One publish destination (ADR 0094). Exactly one of:
5
5
  # to-target { to:, template:?, inject_boot:? } — render data through a
6
6
  # template, or copy verbatim when no template
@@ -0,0 +1,30 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ class React
5
+ ALLOWED_KEYS = %w[on when do scope budget idempotency observe priority].freeze
6
+
7
+ attr_reader :raw
8
+
9
+ def initialize(raw:)
10
+ raise Textus::BadManifest.new("react: must be a map") unless raw.is_a?(Hash)
11
+
12
+ raw = raw.each_with_object({}) do |(key, value), out|
13
+ normalized = key == true ? "on" : key.to_s
14
+ out[normalized] = value
15
+ end
16
+ raise Textus::BadManifest.new("react.ttl is invalid; ttl belongs only to source.ttl or retention.ttl") if raw.key?("ttl")
17
+
18
+ unknown = raw.keys - ALLOWED_KEYS
19
+ raise Textus::BadManifest.new("react: unknown key(s): #{unknown.join(", ")}") unless unknown.empty?
20
+
21
+ @raw = raw
22
+ end
23
+
24
+ def to_h
25
+ @raw
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end