textus 0.35.1 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -1
  3. data/README.md +7 -8
  4. data/SPEC.md +8 -2
  5. data/docs/conventions.md +1 -1
  6. data/lib/textus/boot.rb +41 -21
  7. data/lib/textus/cli/verb/mcp_serve.rb +8 -3
  8. data/lib/textus/cli/verb/propose.rb +28 -0
  9. data/lib/textus/cli/verb/pulse.rb +12 -3
  10. data/lib/textus/cli/verb/schema.rb +1 -1
  11. data/lib/textus/cli/verb.rb +3 -2
  12. data/lib/textus/contract.rb +106 -0
  13. data/lib/textus/cursor_store.rb +24 -0
  14. data/lib/textus/dispatcher.rb +3 -1
  15. data/lib/textus/doctor/check/audit_log.rb +1 -1
  16. data/lib/textus/doctor/check/fetch_locks.rb +2 -2
  17. data/lib/textus/domain/policy/evaluation.rb +3 -6
  18. data/lib/textus/init.rb +4 -0
  19. data/lib/textus/layout.rb +41 -0
  20. data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
  21. data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
  22. data/lib/textus/maintenance/migrate.rb +9 -0
  23. data/lib/textus/maintenance/rule_lint.rb +8 -0
  24. data/lib/textus/maintenance/zone_mv.rb +10 -0
  25. data/lib/textus/mcp/catalog.rb +72 -0
  26. data/lib/textus/mcp/server.rb +8 -5
  27. data/lib/textus/mcp/session.rb +3 -20
  28. data/lib/textus/mcp/tool_schemas.rb +6 -62
  29. data/lib/textus/mcp/tools.rb +4 -119
  30. data/lib/textus/ports/audit_log.rb +17 -15
  31. data/lib/textus/ports/build_lock.rb +1 -2
  32. data/lib/textus/ports/fetch/lock.rb +1 -1
  33. data/lib/textus/read/audit.rb +3 -3
  34. data/lib/textus/read/boot.rb +6 -0
  35. data/lib/textus/read/get.rb +8 -0
  36. data/lib/textus/read/list.rb +8 -0
  37. data/lib/textus/read/pulse.rb +7 -0
  38. data/lib/textus/read/rules.rb +24 -0
  39. data/lib/textus/read/schema_envelope.rb +7 -0
  40. data/lib/textus/role.rb +6 -2
  41. data/lib/textus/session.rb +24 -0
  42. data/lib/textus/store.rb +11 -0
  43. data/lib/textus/version.rb +1 -1
  44. data/lib/textus/write/accept.rb +1 -1
  45. data/lib/textus/write/delete.rb +1 -1
  46. data/lib/textus/write/fetch_all.rb +8 -0
  47. data/lib/textus/write/fetch_worker.rb +9 -1
  48. data/lib/textus/write/mv.rb +1 -1
  49. data/lib/textus/write/propose.rb +46 -0
  50. data/lib/textus/write/put.rb +13 -1
  51. data/lib/textus/write/reject.rb +1 -1
  52. data/lib/textus.rb +4 -0
  53. metadata +13 -4
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ module Read
3
+ # Effective rule set (fetch + guard) for a key. Was the inlined MCP
4
+ # `rules` tool; promoted to a first-class verb so MCP is a pure projection
5
+ # (ADR 0039).
6
+ class Rules
7
+ extend Textus::Contract::DSL
8
+
9
+ verb :rules
10
+ summary "Return effective rules for a key (fetch, guard, ...)."
11
+ surfaces :ruby, :mcp
12
+ arg :key, String, required: true, positional: true
13
+
14
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
15
+ @manifest = container.manifest
16
+ end
17
+
18
+ def call(key)
19
+ set = @manifest.rules.for(key)
20
+ { "fetch" => set.fetch&.to_h, "guard" => set.guard }.compact
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,6 +1,13 @@
1
1
  module Textus
2
2
  module Read
3
3
  class SchemaEnvelope
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :schema
7
+ summary "Return the schema (field shape) for an entry's family, by key."
8
+ surfaces :ruby, :mcp
9
+ arg :key, String, required: true, positional: true
10
+
4
11
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
12
  @manifest = container.manifest
6
13
  @schemas = container.schemas
data/lib/textus/role.rb CHANGED
@@ -2,9 +2,13 @@ module Textus
2
2
  module Role
3
3
  PATTERN = /\A[a-z][a-z0-9_-]*\z/
4
4
  DEFAULT = "human".freeze
5
+ # The default acting identity for the MCP transport (ADR 0040): an agent
6
+ # over stdio proposes; it does not inherit the human's authority. CLI
7
+ # callers keep the `human` DEFAULT.
8
+ AGENT = "agent".freeze
5
9
 
6
- def self.resolve(root:, flag: nil, env: ENV)
7
- candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || DEFAULT
10
+ def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
11
+ candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
8
12
  raise InvalidRole.new(candidate) unless candidate.match?(PATTERN)
9
13
 
10
14
  candidate
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ # The agent session: per-connection (MCP), per-process (CLI), or per-loop
3
+ # (Ruby) orientation state — the audit cursor plus the manifest etag and
4
+ # propose_zone captured at boot. Immutable Data value; advance_cursor
5
+ # returns a new instance. ADR 0036.
6
+ Session = Data.define(:role, :cursor, :propose_zone, :manifest_etag) do
7
+ def advance_cursor(new_cursor) = with(cursor: new_cursor)
8
+
9
+ def check_etag!(observed_etag)
10
+ return if observed_etag == manifest_etag
11
+
12
+ raise Textus::MCP::ContractDrift.new(
13
+ "manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
14
+ )
15
+ end
16
+
17
+ private
18
+
19
+ # First 8 hex chars after the "sha256:" prefix — a stable short id for
20
+ # the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
21
+ # a no-op when the prefix is absent).
22
+ def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
23
+ end
24
+ end
data/lib/textus/store.rb CHANGED
@@ -50,6 +50,17 @@ module Textus
50
50
  @container ||= Textus::Container.from_store(self)
51
51
  end
52
52
 
53
+ # Build an agent Session oriented at the current cursor/manifest — the
54
+ # Ruby equivalent of an MCP `initialize`. ADR 0036.
55
+ def session(role:)
56
+ Textus::Session.new(
57
+ role: role,
58
+ cursor: audit_log.latest_seq,
59
+ propose_zone: manifest.policy.propose_zone_for(role),
60
+ manifest_etag: file_store.etag(File.join(root, "manifest.yaml")),
61
+ )
62
+ end
63
+
53
64
  def as(role, dry_run: false, correlation_id: nil)
54
65
  RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
55
66
  end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.35.1"
2
+ VERSION = "0.38.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -18,7 +18,7 @@ module Textus
18
18
  guard.for(:accept, target).check!(
19
19
  Textus::Domain::Policy::Evaluation.new(
20
20
  actor: @call.role, transition: :accept, origin: pending_key,
21
- target: target, envelope: env, snapshot: @manifest
21
+ target: target, envelope: env, manifest: @manifest
22
22
  ),
23
23
  )
24
24
 
@@ -36,7 +36,7 @@ module Textus
36
36
  def eval_for(transition, target_key:, envelope: nil)
37
37
  Textus::Domain::Policy::Evaluation.new(
38
38
  actor: @call.role, transition: transition, origin: nil,
39
- target: target_key, envelope: envelope, snapshot: @manifest
39
+ target: target_key, envelope: envelope, manifest: @manifest
40
40
  )
41
41
  end
42
42
 
@@ -1,6 +1,14 @@
1
1
  module Textus
2
2
  module Write
3
3
  class FetchAll
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :fetch_all
7
+ summary "Fetch all stale quarantine entries, optionally scoped by zone/prefix."
8
+ surfaces :cli, :ruby, :mcp
9
+ arg :prefix, String
10
+ arg :zone, String
11
+
4
12
  def initialize(container:, call:)
5
13
  @container = container
6
14
  @call = call
@@ -3,6 +3,14 @@ require "timeout"
3
3
  module Textus
4
4
  module Write
5
5
  class FetchWorker
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :fetch
9
+ summary "Run a fetch action for one quarantine entry."
10
+ surfaces :cli, :ruby, :mcp
11
+ arg :key, String, required: true, positional: true
12
+ response { |outcome| { "outcome" => outcome.class.name.split("::").last.downcase } }
13
+
6
14
  FETCH_TIMEOUT_SECONDS = IntakeFetch::FETCH_TIMEOUT_SECONDS
7
15
 
8
16
  def initialize(container:, call:)
@@ -101,7 +109,7 @@ module Textus
101
109
  ).for(:fetch, key).check!(
102
110
  Textus::Domain::Policy::Evaluation.new(
103
111
  actor: @call.role, transition: :fetch, origin: nil,
104
- target: key, envelope: nil, snapshot: @manifest
112
+ target: key, envelope: nil, manifest: @manifest
105
113
  ),
106
114
  )
107
115
  envelope = writer.put(
@@ -109,7 +109,7 @@ module Textus
109
109
  def eval_for(transition, target_key:, envelope: nil)
110
110
  Textus::Domain::Policy::Evaluation.new(
111
111
  actor: @call.role, transition: transition, origin: nil,
112
- target: target_key, envelope: envelope, snapshot: @manifest
112
+ target: target_key, envelope: envelope, manifest: @manifest
113
113
  )
114
114
  end
115
115
 
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ module Write
3
+ # Queue a proposal: resolve the acting role's propose_zone, prefix the key,
4
+ # and write there via the Put verb. Was inlined in the MCP `propose` tool
5
+ # and the CLI propose verb; promoted to a first-class verb so all three
6
+ # transports share one implementation (ADR 0036, ADR 0039).
7
+ class Propose
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :propose
11
+ summary "Write a proposal to the role's propose_zone. Auto-prefixes the key."
12
+ surfaces :cli, :ruby, :mcp
13
+ arg :key, String, required: true, positional: true,
14
+ description: "key relative to propose_zone, e.g. 'decisions.feature-x'"
15
+ arg :meta, Hash, required: true
16
+ arg :body, String
17
+ arg :content, Hash
18
+ response { |env| { "uid" => env.uid, "etag" => env.etag, "key" => env.key } }
19
+
20
+ def initialize(container:, call:)
21
+ @container = container
22
+ @call = call
23
+ @manifest = container.manifest
24
+ end
25
+
26
+ # if_etag is intentionally absent: a proposal is always a fresh queue write.
27
+ def call(key, meta: nil, body: nil, content: nil)
28
+ zone = @manifest.policy.propose_zone_for(@call.role)
29
+ unless zone
30
+ raise Textus::Error.new(
31
+ "propose_forbidden",
32
+ "role '#{@call.role}' has no writable propose_zone",
33
+ details: { "role" => @call.role },
34
+ hint: "the manifest must define a queue zone and '#{@call.role}' must hold the 'propose' capability",
35
+ )
36
+ end
37
+
38
+ Textus::Dispatcher.invoke(
39
+ :put, container: @container, call: @call,
40
+ args: ["#{zone}.#{key}"],
41
+ kwargs: { meta: meta || {}, body: body, content: content }
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,6 +1,18 @@
1
1
  module Textus
2
2
  module Write
3
3
  class Put
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :put
7
+ summary "Create or update an entry. Schema-validated. Returns {uid, etag}."
8
+ surfaces :cli, :ruby, :mcp
9
+ arg :key, String, required: true, positional: true
10
+ arg :meta, Hash, required: true
11
+ arg :body, String
12
+ arg :content, Hash
13
+ arg :if_etag, String
14
+ response { |env| { "uid" => env.uid, "etag" => env.etag } }
15
+
4
16
  def initialize(container:, call:)
5
17
  @container = container
6
18
  @call = call
@@ -41,7 +53,7 @@ module Textus
41
53
  def eval_for(transition, target_key:, envelope: nil)
42
54
  Textus::Domain::Policy::Evaluation.new(
43
55
  actor: @call.role, transition: transition, origin: nil,
44
- target: target_key, envelope: envelope, snapshot: @manifest
56
+ target: target_key, envelope: envelope, manifest: @manifest
45
57
  )
46
58
  end
47
59
 
@@ -13,7 +13,7 @@ module Textus
13
13
  guard.for(:reject, pending_key).check!(
14
14
  Textus::Domain::Policy::Evaluation.new(
15
15
  actor: @call.role, transition: :reject, origin: pending_key,
16
- target: pending_key, envelope: nil, snapshot: @manifest
16
+ target: pending_key, envelope: nil, manifest: @manifest
17
17
  ),
18
18
  )
19
19
 
data/lib/textus.rb CHANGED
@@ -20,6 +20,10 @@ loader.ignore(File.expand_path("textus/mcp/errors.rb", __dir__))
20
20
  loader.setup
21
21
  loader.eager_load
22
22
 
23
+ # Derive CLI_VERBS after eager_load so all contract-declaring files are present
24
+ # (boot.rb loads first alphabetically; Dispatcher contracts are declared later).
25
+ Textus::Boot::CLI_VERBS = Textus::Boot.build_cli_verbs.freeze
26
+
23
27
  module Textus
24
28
  @hook_mutex = Mutex.new
25
29
  @hook_blocks = []
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.35.1
4
+ version: 0.38.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -93,8 +93,9 @@ dependencies:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
95
  version: '3.13'
96
- description: Storage convention and JSON wire protocol for agent-readable project
97
- memory.
96
+ description: A coordination space for humans, AI, and automation. Durable, multi-writer
97
+ project memory where each actor writes into its own lane, proposals cross a review
98
+ queue, and every change is audited.
98
99
  email:
99
100
  - patrick204nqh@gmail.com
100
101
  executables:
@@ -147,6 +148,7 @@ files:
147
148
  - lib/textus/cli/verb/mcp_serve.rb
148
149
  - lib/textus/cli/verb/migrate.rb
149
150
  - lib/textus/cli/verb/mv.rb
151
+ - lib/textus/cli/verb/propose.rb
150
152
  - lib/textus/cli/verb/published.rb
151
153
  - lib/textus/cli/verb/pulse.rb
152
154
  - lib/textus/cli/verb/put.rb
@@ -164,6 +166,8 @@ files:
164
166
  - lib/textus/cli/verb/where.rb
165
167
  - lib/textus/cli/verb/zone_mv.rb
166
168
  - lib/textus/container.rb
169
+ - lib/textus/contract.rb
170
+ - lib/textus/cursor_store.rb
167
171
  - lib/textus/dispatcher.rb
168
172
  - lib/textus/doctor.rb
169
173
  - lib/textus/doctor/check.rb
@@ -234,6 +238,7 @@ files:
234
238
  - lib/textus/key/distance.rb
235
239
  - lib/textus/key/grammar.rb
236
240
  - lib/textus/key/path.rb
241
+ - lib/textus/layout.rb
237
242
  - lib/textus/maintenance.rb
238
243
  - lib/textus/maintenance/key_delete_prefix.rb
239
244
  - lib/textus/maintenance/key_mv_prefix.rb
@@ -261,6 +266,7 @@ files:
261
266
  - lib/textus/manifest/rules.rb
262
267
  - lib/textus/manifest/schema.rb
263
268
  - lib/textus/mcp.rb
269
+ - lib/textus/mcp/catalog.rb
264
270
  - lib/textus/mcp/errors.rb
265
271
  - lib/textus/mcp/server.rb
266
272
  - lib/textus/mcp/session.rb
@@ -292,6 +298,7 @@ files:
292
298
  - lib/textus/read/pulse.rb
293
299
  - lib/textus/read/rdeps.rb
294
300
  - lib/textus/read/retainable.rb
301
+ - lib/textus/read/rules.rb
295
302
  - lib/textus/read/schema_envelope.rb
296
303
  - lib/textus/read/stale.rb
297
304
  - lib/textus/read/uid.rb
@@ -303,6 +310,7 @@ files:
303
310
  - lib/textus/schema.rb
304
311
  - lib/textus/schema/tools.rb
305
312
  - lib/textus/schemas.rb
313
+ - lib/textus/session.rb
306
314
  - lib/textus/store.rb
307
315
  - lib/textus/uid.rb
308
316
  - lib/textus/version.rb
@@ -314,6 +322,7 @@ files:
314
322
  - lib/textus/write/intake_fetch.rb
315
323
  - lib/textus/write/materializer.rb
316
324
  - lib/textus/write/mv.rb
325
+ - lib/textus/write/propose.rb
317
326
  - lib/textus/write/publish.rb
318
327
  - lib/textus/write/put.rb
319
328
  - lib/textus/write/reject.rb
@@ -344,5 +353,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
344
353
  requirements: []
345
354
  rubygems_version: 3.6.9
346
355
  specification_version: 4
347
- summary: Reference implementation of the textus/1 protocol.
356
+ summary: Reference implementation of the textus/3 protocol.
348
357
  test_files: []