textus 0.54.2 → 0.55.1

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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +8 -1
  4. data/SPEC.md +27 -0
  5. data/docs/architecture/README.md +20 -8
  6. data/docs/reference/conventions.md +1 -1
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +23 -21
  9. data/lib/textus/action/audit.rb +24 -61
  10. data/lib/textus/action/base.rb +9 -9
  11. data/lib/textus/action/blame.rb +18 -36
  12. data/lib/textus/action/boot.rb +2 -4
  13. data/lib/textus/action/data_mv.rb +20 -31
  14. data/lib/textus/action/deps.rb +3 -18
  15. data/lib/textus/action/doctor.rb +2 -9
  16. data/lib/textus/action/drain.rb +11 -19
  17. data/lib/textus/action/enqueue.rb +14 -30
  18. data/lib/textus/action/get.rb +12 -56
  19. data/lib/textus/action/ingest.rb +74 -78
  20. data/lib/textus/action/jobs.rb +6 -15
  21. data/lib/textus/action/key_delete.rb +6 -16
  22. data/lib/textus/action/key_delete_prefix.rb +8 -17
  23. data/lib/textus/action/key_mv.rb +54 -61
  24. data/lib/textus/action/key_mv_prefix.rb +13 -22
  25. data/lib/textus/action/list.rb +7 -21
  26. data/lib/textus/action/propose.rb +16 -26
  27. data/lib/textus/action/published.rb +3 -5
  28. data/lib/textus/action/pulse.rb +19 -26
  29. data/lib/textus/action/put.rb +15 -29
  30. data/lib/textus/action/rdeps.rb +3 -18
  31. data/lib/textus/action/reject.rb +12 -21
  32. data/lib/textus/action/rule_explain.rb +12 -22
  33. data/lib/textus/action/rule_lint.rb +10 -16
  34. data/lib/textus/action/rule_list.rb +5 -9
  35. data/lib/textus/action/schema_envelope.rb +3 -10
  36. data/lib/textus/action/uid.rb +3 -17
  37. data/lib/textus/action/where.rb +3 -18
  38. data/lib/textus/boot.rb +7 -15
  39. data/lib/textus/contract/arg.rb +10 -0
  40. data/lib/textus/contract/dsl.rb +88 -0
  41. data/lib/textus/contract/spec.rb +25 -0
  42. data/lib/textus/contract.rb +0 -162
  43. data/lib/textus/doctor/check/audit_log.rb +2 -2
  44. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  45. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  46. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  47. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  48. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  49. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  50. data/lib/textus/doctor/check/schemas.rb +1 -1
  51. data/lib/textus/doctor/check/sentinels.rb +4 -4
  52. data/lib/textus/doctor/check/templates.rb +1 -1
  53. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  54. data/lib/textus/doctor/check.rb +4 -7
  55. data/lib/textus/doctor.rb +1 -1
  56. data/lib/textus/errors.rb +6 -0
  57. data/lib/textus/format/base.rb +0 -4
  58. data/lib/textus/format/json.rb +5 -6
  59. data/lib/textus/format/markdown.rb +5 -6
  60. data/lib/textus/format/shared.rb +17 -0
  61. data/lib/textus/format/text.rb +5 -4
  62. data/lib/textus/format/yaml.rb +30 -6
  63. data/lib/textus/format.rb +6 -0
  64. data/lib/textus/gate/auth.rb +2 -17
  65. data/lib/textus/gate/binder.rb +50 -0
  66. data/lib/textus/gate.rb +64 -88
  67. data/lib/textus/init.rb +2 -4
  68. data/lib/textus/jobs.rb +3 -9
  69. data/lib/textus/manifest/capabilities.rb +3 -3
  70. data/lib/textus/manifest/entry/base.rb +1 -1
  71. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  73. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  74. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  75. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  76. data/lib/textus/manifest/schema/semantics.rb +11 -216
  77. data/lib/textus/meta.rb +54 -0
  78. data/lib/textus/{ports → port}/audit_log.rb +44 -4
  79. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  80. data/lib/textus/{ports → port}/clock.rb +1 -1
  81. data/lib/textus/{ports → port}/publisher.rb +5 -5
  82. data/lib/textus/{ports → port}/sentinel_store.rb +3 -3
  83. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  84. data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
  85. data/lib/textus/port/store.rb +93 -0
  86. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  87. data/lib/textus/produce/engine.rb +1 -1
  88. data/lib/textus/schema/tools.rb +11 -7
  89. data/lib/textus/store/compositor.rb +34 -0
  90. data/lib/textus/store/container.rb +43 -0
  91. data/lib/textus/store/cursor.rb +26 -0
  92. data/lib/textus/store/envelope/reader.rb +43 -0
  93. data/lib/textus/store/envelope/writer.rb +195 -0
  94. data/lib/textus/store/geometry.rb +81 -0
  95. data/lib/textus/store/index/builder.rb +74 -0
  96. data/lib/textus/store/index/lookup.rb +60 -0
  97. data/lib/textus/store/jobs/base.rb +13 -0
  98. data/lib/textus/store/jobs/index.rb +15 -0
  99. data/lib/textus/store/jobs/materialize.rb +15 -0
  100. data/lib/textus/store/jobs/plan.rb +11 -0
  101. data/lib/textus/store/jobs/planner.rb +104 -0
  102. data/lib/textus/store/jobs/queue.rb +154 -0
  103. data/lib/textus/store/jobs/registry.rb +19 -0
  104. data/lib/textus/store/jobs/retention.rb +50 -0
  105. data/lib/textus/store/jobs/sweep.rb +21 -0
  106. data/lib/textus/store/jobs/worker.rb +64 -0
  107. data/lib/textus/store/session.rb +37 -0
  108. data/lib/textus/store.rb +21 -13
  109. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  110. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  111. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  112. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  113. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  114. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  115. data/lib/textus/{surfaces → surface}/cli/runner.rb +10 -63
  116. data/lib/textus/surface/cli/sources.rb +41 -0
  117. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
  118. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
  119. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  120. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  121. data/lib/textus/{surfaces → surface}/cli/verb/put.rb +6 -11
  122. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  123. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  124. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  125. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  126. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  127. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  128. data/lib/textus/{surfaces → surface}/mcp/catalog.rb +9 -26
  129. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
  130. data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
  131. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  132. data/lib/textus/surface/projector.rb +27 -0
  133. data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  135. data/lib/textus/value/call.rb +30 -0
  136. data/lib/textus/value/command.rb +16 -0
  137. data/lib/textus/value/envelope.rb +89 -0
  138. data/lib/textus/value/etag.rb +39 -0
  139. data/lib/textus/value/result.rb +26 -0
  140. data/lib/textus/value/role.rb +38 -0
  141. data/lib/textus/value/types.rb +13 -0
  142. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  143. data/lib/textus/version.rb +1 -1
  144. data/lib/textus/workflow/loader.rb +4 -4
  145. data/lib/textus/workflow/runner.rb +4 -18
  146. data/lib/textus.rb +9 -10
  147. metadata +100 -63
  148. data/lib/textus/action/write_verb.rb +0 -44
  149. data/lib/textus/call.rb +0 -28
  150. data/lib/textus/command.rb +0 -41
  151. data/lib/textus/container.rb +0 -26
  152. data/lib/textus/contract/around.rb +0 -29
  153. data/lib/textus/contract/binder.rb +0 -88
  154. data/lib/textus/contract/resources/build_lock.rb +0 -17
  155. data/lib/textus/contract/resources/cursor.rb +0 -26
  156. data/lib/textus/contract/sources.rb +0 -39
  157. data/lib/textus/contract/view.rb +0 -15
  158. data/lib/textus/cursor_store.rb +0 -24
  159. data/lib/textus/envelope/reader.rb +0 -46
  160. data/lib/textus/envelope/writer.rb +0 -209
  161. data/lib/textus/envelope.rb +0 -79
  162. data/lib/textus/etag.rb +0 -36
  163. data/lib/textus/jobs/base.rb +0 -23
  164. data/lib/textus/jobs/materialize.rb +0 -20
  165. data/lib/textus/jobs/plan.rb +0 -9
  166. data/lib/textus/jobs/planner.rb +0 -101
  167. data/lib/textus/jobs/retention.rb +0 -48
  168. data/lib/textus/jobs/sweep.rb +0 -27
  169. data/lib/textus/jobs/worker.rb +0 -67
  170. data/lib/textus/layout.rb +0 -91
  171. data/lib/textus/ports/job_store/job.rb +0 -65
  172. data/lib/textus/ports/job_store.rb +0 -123
  173. data/lib/textus/ports/raw_index.rb +0 -61
  174. data/lib/textus/role.rb +0 -36
  175. data/lib/textus/session.rb +0 -35
  176. data/lib/textus/types.rb +0 -15
@@ -1,67 +0,0 @@
1
- module Textus
2
- module Jobs
3
- class Worker
4
- Summary = Struct.new(:completed, :failed, keyword_init: true)
5
-
6
- def self.for(container:, queue:)
7
- new(queue: queue, container: container,
8
- lease_ttl: container.manifest.data.worker_config[:lease_ttl])
9
- end
10
-
11
- def initialize(queue:, container:, lease_ttl: 60)
12
- @queue = queue
13
- @container = container
14
- @lease_ttl = lease_ttl
15
- end
16
-
17
- def drain(worker_id: "drain-#{Process.pid}")
18
- completed = 0
19
- failed = 0
20
- loop do
21
- leased = @queue.lease(worker_id: worker_id, lease_ttl: @lease_ttl)
22
- break unless leased
23
-
24
- case run_one(leased)
25
- when :completed then completed += 1
26
- when :dead_lettered then failed += 1
27
- end
28
- end
29
- Summary.new(completed: completed, failed: failed)
30
- end
31
-
32
- def drain_pool(pool: 4)
33
- summaries = []
34
- mutex = Mutex.new
35
- threads = Array.new(pool) do |i|
36
- Thread.new do
37
- s = drain(worker_id: "pool-#{Process.pid}-#{i}")
38
- mutex.synchronize { summaries << s }
39
- end
40
- end
41
- threads.each(&:join)
42
- Summary.new(completed: summaries.sum(&:completed), failed: summaries.sum(&:failed))
43
- end
44
-
45
- private
46
-
47
- def run_one(leased)
48
- job = leased.job
49
- klass = Textus::Jobs.fetch(job.type)
50
- action = if klass.instance_method(:initialize).parameters.any?
51
- klass.new(**job.args.transform_keys(&:to_sym))
52
- else
53
- klass.new
54
- end
55
- call = Textus::Call.build(
56
- role: job.enqueued_by || Textus::Role::AUTOMATION,
57
- correlation_id: SecureRandom.uuid,
58
- )
59
- action.call(container: @container, call: call)
60
- @queue.ack(leased)
61
- :completed
62
- rescue StandardError => e
63
- @queue.fail(leased, error: e.message)
64
- end
65
- end
66
- end
67
- end
data/lib/textus/layout.rb DELETED
@@ -1,91 +0,0 @@
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>/.state/ so the
4
- # tracked/disposable boundary is a directory boundary. ADR 0038.
5
- module Layout
6
- RUN = ".state"
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
16
-
17
- def self.run(root)
18
- File.join(root, RUN)
19
- end
20
-
21
- def self.cursors(root)
22
- File.join(run(root), "cursors")
23
- end
24
-
25
- def self.cursor(root, role)
26
- File.join(cursors(root), role.to_s)
27
- end
28
-
29
- def self.locks(root)
30
- File.join(run(root), "locks")
31
- end
32
-
33
- def self.build_lock(root)
34
- File.join(run(root), "build.lock")
35
- end
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
-
49
- def self.audit_dir(root)
50
- File.join(run(root), "audit")
51
- end
52
-
53
- # Sentinels are machine-generated (the published target's sha), not authored
54
- # source, so they live on the runtime side under `.state/` — git-ignored,
55
- # regenerated by the next build via content-identical adoption (ADR 0070,
56
- # superseding ADR 0038's `:config` classification).
57
- def self.sentinels(root)
58
- File.join(run(root), "sentinels")
59
- end
60
-
61
- def self.indexes(root)
62
- File.join(run(root), "indexes")
63
- end
64
-
65
- def self.raw_index(root)
66
- File.join(indexes(root), "raw.yaml")
67
- end
68
-
69
- def self.audit_log(root)
70
- File.join(audit_dir(root), "audit.log")
71
- end
72
-
73
- # The store's `.gitignore` body. Always ignores the runtime subtree
74
- # (`.state/`, ADR 0038); when given untracked entry paths (entries marked
75
- # `tracked: false`), it also lists those so they stay protocol-readable but
76
- # uncommitted (ADR 0043, refining 0038). Generated, never hand-kept — no
77
- # drift between the manifest and the ignore file.
78
- def self.gitignore_body(untracked_paths: [])
79
- lines = ["# textus runtime artifacts — safe to delete, never commit",
80
- "#{RUN}/"]
81
- unless untracked_paths.empty?
82
- lines << "# tracked:false entries — protocol-readable, not committed (sensitive)"
83
- lines.concat(untracked_paths)
84
- end
85
- "#{lines.join("\n")}\n"
86
- end
87
-
88
- # Back-compat constant: the no-untracked-entries body (just the run subtree).
89
- GITIGNORE = gitignore_body
90
- end
91
- end
@@ -1,65 +0,0 @@
1
- require "digest"
2
- require "json"
3
-
4
- module Textus
5
- module Ports
6
- class JobStore
7
- # A unit of deferred work. Pure data. The id is `<type>:<digest>` where the
8
- # digest is over the args with sorted keys, so logically-identical enqueues
9
- # collide on the same id — which is how Queue dedups (file already exists).
10
- # At-least-once delivery means handlers must be idempotent.
11
- class Job
12
- DIGEST_BYTES = 16
13
-
14
- attr_reader :type, :args, :enqueued_by, :max_attempts
15
- attr_accessor :attempts, :last_error
16
-
17
- def initialize(type:, args:, enqueued_by: nil, attempts: 0, max_attempts: 3, last_error: nil)
18
- @type = type.to_s
19
- @args = stringify(args)
20
- @enqueued_by = enqueued_by
21
- @attempts = attempts
22
- @max_attempts = max_attempts
23
- @last_error = last_error
24
- end
25
-
26
- def id
27
- "#{@type}:#{digest}"
28
- end
29
-
30
- def to_h
31
- {
32
- "type" => @type,
33
- "args" => @args,
34
- "enqueued_by" => @enqueued_by,
35
- "attempts" => @attempts,
36
- "max_attempts" => @max_attempts,
37
- "last_error" => @last_error,
38
- }
39
- end
40
-
41
- def self.from_h(hash)
42
- new(
43
- type: hash["type"],
44
- args: hash["args"] || {},
45
- enqueued_by: hash["enqueued_by"],
46
- attempts: hash["attempts"] || 0,
47
- max_attempts: hash["max_attempts"] || 3,
48
- last_error: hash["last_error"],
49
- )
50
- end
51
-
52
- private
53
-
54
- def digest
55
- canonical = JSON.dump(@args.sort.to_h)
56
- Digest::SHA256.hexdigest(canonical)[0, DIGEST_BYTES]
57
- end
58
-
59
- def stringify(hash)
60
- hash.transform_keys(&:to_s)
61
- end
62
- end
63
- end
64
- end
65
- end
@@ -1,123 +0,0 @@
1
- require "fileutils"
2
- require "json"
3
- require "time"
4
-
5
- module Textus
6
- module Ports
7
- # File-backed durable job queue under `<root>/.run/queue/`. Each job state
8
- # is a directory; a job is one `<id>.json` file. Claiming is an atomic
9
- # `rename(2)` from ready/ to leased/ — the rename winner owns the job, so a
10
- # worker pool needs no central lock. Dedup falls out of the id-as-filename:
11
- # enqueueing an id that already exists is a no-op. ADR 0038 (runtime subtree),
12
- # ADR 0108 (instantiable port).
13
- class JobStore
14
- STATES = %i[ready leased done failed].freeze
15
-
16
- def initialize(root:)
17
- @root = root
18
- STATES.each { |s| FileUtils.mkdir_p(Textus::Layout.queue_state(root, s)) }
19
- end
20
-
21
- def enqueue(job)
22
- dest = path(:ready, job.id)
23
- return if File.exist?(dest)
24
-
25
- write_atomic(dest, job.to_h)
26
- end
27
-
28
- def ready_ids
29
- Dir.children(Textus::Layout.queue_state(@root, :ready)).map { |f| File.basename(f, ".json") }
30
- end
31
-
32
- Leased = Struct.new(:job, :leased_path, keyword_init: true)
33
-
34
- def lease(worker_id:, lease_ttl:)
35
- ready_dir = Textus::Layout.queue_state(@root, :ready)
36
- Dir.children(ready_dir).each do |name|
37
- src = File.join(ready_dir, name)
38
- dst = File.join(Textus::Layout.queue_state(@root, :leased), name)
39
- begin
40
- File.rename(src, dst)
41
- rescue Errno::ENOENT
42
- next
43
- end
44
- job = Job.from_h(JSON.parse(File.read(dst)))
45
- stamp_lease(dst, worker_id: worker_id, expires_at: Time.now.utc + lease_ttl)
46
- return Leased.new(job: job, leased_path: dst)
47
- end
48
- nil
49
- end
50
-
51
- def ack(leased)
52
- dest = File.join(Textus::Layout.queue_state(@root, :done), File.basename(leased.leased_path))
53
- File.rename(leased.leased_path, dest)
54
- end
55
-
56
- def fail(leased, error:)
57
- job = leased.job
58
- job.attempts += 1
59
- job.last_error = error
60
- dead = job.attempts >= job.max_attempts
61
- write_atomic(path(dead ? :failed : :ready, job.id), job.to_h)
62
- File.delete(leased.leased_path)
63
- dead ? :dead_lettered : :requeued
64
- end
65
-
66
- def reclaim(now:)
67
- leased_dir = Textus::Layout.queue_state(@root, :leased)
68
- count = 0
69
- Dir.children(leased_dir).each do |name|
70
- src = File.join(leased_dir, name)
71
- data = JSON.parse(File.read(src))
72
- expires = data.dig("lease", "expires_at")
73
- next if expires && Time.parse(expires) > now
74
-
75
- dst = File.join(Textus::Layout.queue_state(@root, :ready), name)
76
- data.delete("lease")
77
- File.write(src, JSON.pretty_generate(data))
78
- File.rename(src, dst)
79
- count += 1
80
- rescue Errno::ENOENT
81
- next
82
- end
83
- count
84
- end
85
-
86
- def list(state)
87
- Dir.children(Textus::Layout.queue_state(@root, state.to_sym)).map { |f| File.basename(f, ".json") }
88
- end
89
-
90
- def retry_failed(job_id)
91
- src = path(:failed, job_id)
92
- data = JSON.parse(File.read(src))
93
- data["attempts"] = 0
94
- data["last_error"] = nil
95
- write_atomic(path(:ready, job_id), data)
96
- File.delete(src)
97
- end
98
-
99
- def purge(state)
100
- dir = Textus::Layout.queue_state(@root, state.to_sym)
101
- Dir.children(dir).each { |f| File.delete(File.join(dir, f)) }
102
- end
103
-
104
- private
105
-
106
- def stamp_lease(leased_path, worker_id:, expires_at:)
107
- data = JSON.parse(File.read(leased_path))
108
- data["lease"] = { "worker_id" => worker_id, "expires_at" => expires_at.iso8601 }
109
- File.write(leased_path, JSON.pretty_generate(data))
110
- end
111
-
112
- def path(state, job_id)
113
- File.join(Textus::Layout.queue_state(@root, state), "#{job_id}.json")
114
- end
115
-
116
- def write_atomic(dest, hash)
117
- tmp = "#{dest}.#{Process.pid}.tmp"
118
- File.write(tmp, JSON.pretty_generate(hash))
119
- File.rename(tmp, dest)
120
- end
121
- end
122
- end
123
- end
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
- require "fileutils"
5
-
6
- module Textus
7
- module Ports
8
- # Content-addressable index for the raw lane. Maps content hashes and URLs
9
- # to their current canonical key. Lives under <root>/.state/indexes/raw.yaml
10
- # (gitignored, regenerable — truth is in the on-disk raw entries).
11
- class RawIndex
12
- def initialize(root:)
13
- @root = root
14
- @path = Layout.raw_index(root)
15
- end
16
-
17
- attr_reader :path
18
-
19
- def load
20
- return empty_index unless File.exist?(@path)
21
-
22
- YAML.safe_load_file(@path) || empty_index
23
- rescue StandardError
24
- empty_index
25
- end
26
-
27
- def save(index)
28
- FileUtils.mkdir_p(File.dirname(@path))
29
- File.write(@path, YAML.dump(index))
30
- index
31
- end
32
-
33
- def find_by_hash(content_hash)
34
- index = load
35
- index["hashes"]&.fetch(content_hash, nil)
36
- end
37
-
38
- def find_by_url(url)
39
- return nil unless url
40
-
41
- index = load
42
- index["urls"]&.fetch(url, nil)
43
- end
44
-
45
- def upsert(content_hash:, url:, key:)
46
- index = load
47
- index["hashes"] ||= {}
48
- index["urls"] ||= {}
49
- index["hashes"][content_hash] = key
50
- index["urls"][url] = key if url
51
- save(index)
52
- end
53
-
54
- private
55
-
56
- def empty_index
57
- { "hashes" => {}, "urls" => {} }
58
- end
59
- end
60
- end
61
- end
data/lib/textus/role.rb DELETED
@@ -1,36 +0,0 @@
1
- module Textus
2
- module Role
3
- # The three role archetypes, each string sourced exactly once: human curates
4
- # canon, agent proposes, automation converges the machine-maintained lanes
5
- # (refresh + materialize) (explanation/concepts.md).
6
- # Reference these constants instead of bare literals (ADR 0044).
7
- HUMAN = "human".freeze
8
- AGENT = "agent".freeze
9
- AUTOMATION = "automation".freeze
10
-
11
- # The closed set of legal role names (ADR 0045), built FROM the archetypes
12
- # above so it stays the single source of truth — a manifest declaring any
13
- # other name is rejected at load, and DEFAULT ∈ NAMES holds structurally.
14
- # Capabilities (`can:`) remain freely tunable per role.
15
- NAMES = [HUMAN, AGENT, AUTOMATION].freeze
16
-
17
- # Default acting identity (ADR 0040): a *choice* over the vocabulary, not a
18
- # new name. CLI callers act as the human; an agent over stdio proposes and
19
- # does not inherit the human's authority (it defaults to AGENT per transport).
20
- DEFAULT = HUMAN
21
-
22
- def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
23
- candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
24
- raise InvalidRole.new(candidate) unless NAMES.include?(candidate)
25
-
26
- candidate
27
- end
28
-
29
- def self.read_file(root)
30
- path = File.join(root, "role")
31
- return nil unless File.exist?(path)
32
-
33
- File.read(path).strip.then { |s| s.empty? ? nil : s }
34
- end
35
- end
36
- end
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry-struct"
4
-
5
- module Textus
6
- # The agent session: per-connection (MCP), per-process (CLI), or per-loop
7
- # (Ruby) orientation state — the audit cursor plus the contract etag and
8
- # propose_lane captured at boot. Immutable Dry::Struct::Value; advance_cursor
9
- # and with return new instances. ADR 0036; contract_etag widened in ADR 0074.
10
- class Session < Dry::Struct
11
- attribute :role, Types::RoleName
12
- attribute :cursor, Types::Cursor
13
- attribute :propose_lane, Types::String.optional
14
- attribute :contract_etag, Types::String
15
-
16
- def with(**attrs) = self.class.new(to_h.merge(attrs))
17
-
18
- def advance_cursor(new_cursor) = with(cursor: new_cursor)
19
-
20
- def check_etag!(observed_etag)
21
- return if observed_etag == contract_etag
22
-
23
- raise Textus::ContractDrift.new(
24
- "contract changed (manifest/hooks/schemas were #{short_etag(contract_etag)}, " \
25
- "now #{short_etag(observed_etag)}); re-run boot",
26
- )
27
- end
28
-
29
- private
30
-
31
- # First 8 hex chars after the "sha256:" prefix — a stable short id for
32
- # the drift diagnostic.
33
- def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
34
- end
35
- end
data/lib/textus/types.rb DELETED
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/types"
4
-
5
- module Textus
6
- module Types
7
- include Dry.Types()
8
-
9
- RoleName = Types::String.constrained(included_in: Textus::Role::NAMES)
10
- Cursor = Types::Integer.constrained(gteq: 0)
11
- FormatName = Types::String.constrained(
12
- included_in: %w[markdown json yaml text], # must match Format::STRATEGIES.keys
13
- )
14
- end
15
- end