textus 0.30.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +2 -241
  3. data/CHANGELOG.md +221 -0
  4. data/README.md +89 -69
  5. data/SPEC.md +359 -212
  6. data/docs/conventions.md +42 -37
  7. data/lib/textus/boot.rb +122 -87
  8. data/lib/textus/cli/group/{refresh.rb → fetch.rb} +4 -4
  9. data/lib/textus/cli/verb/build.rb +1 -1
  10. data/lib/textus/cli/verb/fetch.rb +14 -0
  11. data/lib/textus/cli/verb/{refresh_stale.rb → fetch_stale.rb} +3 -3
  12. data/lib/textus/cli/verb/get.rb +1 -1
  13. data/lib/textus/cli/verb/hooks.rb +1 -1
  14. data/lib/textus/cli/verb/mcp_serve.rb +8 -3
  15. data/lib/textus/cli/verb/propose.rb +28 -0
  16. data/lib/textus/cli/verb/pulse.rb +12 -3
  17. data/lib/textus/cli/verb/put.rb +1 -1
  18. data/lib/textus/cli/verb/rule_list.rb +7 -7
  19. data/lib/textus/cli/verb/schema.rb +1 -1
  20. data/lib/textus/cli/verb.rb +3 -2
  21. data/lib/textus/cli.rb +2 -2
  22. data/lib/textus/container.rb +1 -2
  23. data/lib/textus/contract.rb +106 -0
  24. data/lib/textus/cursor_store.rb +24 -0
  25. data/lib/textus/dispatcher.rb +6 -4
  26. data/lib/textus/doctor/check/audit_log.rb +1 -1
  27. data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +8 -8
  28. data/lib/textus/doctor/check/proposal_targets.rb +45 -0
  29. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  30. data/lib/textus/doctor.rb +2 -1
  31. data/lib/textus/domain/action.rb +3 -3
  32. data/lib/textus/domain/freshness/evaluator.rb +3 -3
  33. data/lib/textus/domain/freshness/policy.rb +2 -2
  34. data/lib/textus/domain/freshness.rb +7 -7
  35. data/lib/textus/domain/outcome.rb +2 -2
  36. data/lib/textus/domain/permission.rb +2 -10
  37. data/lib/textus/domain/policy/base_guards.rb +25 -0
  38. data/lib/textus/domain/policy/evaluation.rb +15 -0
  39. data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +1 -1
  40. data/lib/textus/domain/policy/guard.rb +35 -0
  41. data/lib/textus/domain/policy/guard_factory.rb +40 -0
  42. data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
  43. data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
  44. data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
  45. data/lib/textus/domain/policy/predicates/registry.rb +39 -0
  46. data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
  47. data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
  48. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
  49. data/lib/textus/domain/staleness/intake_check.rb +6 -6
  50. data/lib/textus/envelope.rb +2 -2
  51. data/lib/textus/errors.rb +25 -28
  52. data/lib/textus/hooks/event_bus.rb +4 -4
  53. data/lib/textus/init.rb +27 -18
  54. data/lib/textus/layout.rb +41 -0
  55. data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
  56. data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
  57. data/lib/textus/maintenance/migrate.rb +9 -0
  58. data/lib/textus/maintenance/rule_lint.rb +8 -0
  59. data/lib/textus/maintenance/zone_mv.rb +11 -1
  60. data/lib/textus/manifest/capabilities.rb +29 -0
  61. data/lib/textus/manifest/data.rb +14 -10
  62. data/lib/textus/manifest/policy.rb +37 -21
  63. data/lib/textus/manifest/rules.rb +16 -14
  64. data/lib/textus/manifest/schema.rb +48 -58
  65. data/lib/textus/manifest.rb +3 -3
  66. data/lib/textus/mcp/catalog.rb +72 -0
  67. data/lib/textus/mcp/server.rb +8 -5
  68. data/lib/textus/mcp/session.rb +3 -20
  69. data/lib/textus/mcp/tool_schemas.rb +6 -62
  70. data/lib/textus/mcp/tools.rb +4 -119
  71. data/lib/textus/ports/audit_log.rb +17 -15
  72. data/lib/textus/ports/audit_subscriber.rb +1 -1
  73. data/lib/textus/ports/build_lock.rb +1 -2
  74. data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
  75. data/lib/textus/ports/{refresh → fetch}/lock.rb +2 -2
  76. data/lib/textus/projection.rb +1 -1
  77. data/lib/textus/read/audit.rb +3 -3
  78. data/lib/textus/read/boot.rb +6 -0
  79. data/lib/textus/read/freshness.rb +9 -9
  80. data/lib/textus/read/get.rb +16 -8
  81. data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
  82. data/lib/textus/read/list.rb +8 -0
  83. data/lib/textus/read/policy_explain.rb +14 -10
  84. data/lib/textus/read/pulse.rb +12 -4
  85. data/lib/textus/read/rules.rb +24 -0
  86. data/lib/textus/read/schema_envelope.rb +7 -0
  87. data/lib/textus/read/validator.rb +1 -1
  88. data/lib/textus/role.rb +6 -2
  89. data/lib/textus/schema/tools.rb +5 -5
  90. data/lib/textus/session.rb +24 -0
  91. data/lib/textus/store.rb +11 -0
  92. data/lib/textus/version.rb +1 -1
  93. data/lib/textus/write/accept.rb +19 -55
  94. data/lib/textus/write/delete.rb +14 -2
  95. data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +14 -6
  96. data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
  97. data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +29 -14
  98. data/lib/textus/write/mv.rb +15 -3
  99. data/lib/textus/write/propose.rb +46 -0
  100. data/lib/textus/write/put.rb +26 -2
  101. data/lib/textus/write/reject.rb +11 -5
  102. data/lib/textus.rb +4 -0
  103. metadata +36 -21
  104. data/lib/textus/cli/verb/refresh.rb +0 -14
  105. data/lib/textus/domain/authorizer.rb +0 -37
  106. data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
  107. data/lib/textus/domain/policy/promote.rb +0 -26
  108. data/lib/textus/domain/policy/promotion.rb +0 -57
  109. data/lib/textus/manifest/role_kinds.rb +0 -21
  110. data/lib/textus/write/authority_gate.rb +0 -24
data/docs/conventions.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # Conventions
2
2
 
3
- Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and integrating with build runners. The spec ([`../SPEC.md`](../SPEC.md)) defines what's enforceable; this document captures what's *idiomatic*.
3
+ > **Reference** · for integrators · **read when** you're shaping a `.textus/` tree and want the idiomatic choices
4
+ > **SSoT for** idiomatic key naming, schema design, and automation integration · **reviewed** 2026-05 (v0.35)
5
+
6
+ Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and integrating with build automation. The spec ([`../SPEC.md`](../SPEC.md)) defines what's enforceable; this document captures what's *idiomatic*.
4
7
 
5
8
  ## Key naming
6
9
 
@@ -18,14 +21,14 @@ Recommended top-level layout — the spec allows alternatives, but this is what
18
21
  manifest.yaml
19
22
  schemas/ # YAML schema definitions
20
23
  zones/
21
- identity/ # identity, voice, slow-changing facts humans only
22
- working/ # agent-writable working memory
23
- intake/ # runner-fed external inputs
24
- review/ # AI proposals awaiting accept
25
- output/ # generated by build runners — never edit by hand
24
+ knowledge/ # authored truth: identity, voice, decisionsauthor-holders write
25
+ notebook/ # agent's own durable lane (workspace) — keep-holders write
26
+ feeds/ # automation-fed external inputs (fetch)
27
+ proposals/ # AI proposals awaiting accept (propose)
28
+ artifacts/ # generated by build automation — never edit by hand
26
29
  ```
27
30
 
28
- Inside `working/`, group by **domain** (people, projects, decisions, runbooks), not by file type or date. Inside `output/`, group by **producer** (`output/catalogs/`, `output/indexes/`) so it's clear which build job owns what.
31
+ Inside `knowledge/`, group by **domain** (identity, people, projects, decisions, runbooks), not by file type or date. `knowledge.identity.*` is the convention for slow-changing identity facts. Inside `artifacts/`, group by **producer** (`artifacts/catalogs/`, `artifacts/indexes/`) so it's clear which build job owns what.
29
32
 
30
33
  ## Schema design
31
34
 
@@ -46,75 +49,77 @@ Tooling around `git blame` or audit logs may filter on owner; the gem itself onl
46
49
 
47
50
  ## Derived entries
48
51
 
49
- textus supports two shapes for derived entries:
52
+ A derived entry declares a `compute:` block with a `kind:` discriminator. Two kinds:
50
53
 
51
- **`projection:`** — textus computes the entry on `textus build` from other store entries. Declarative; nothing shells out.
54
+ **`compute: { kind: projection }`** — textus computes the entry on `textus build` from other store entries. Declarative; nothing shells out.
52
55
 
53
56
  ```yaml
54
- - key: output.catalogs.people
55
- path: output/catalogs/people.md
56
- zone: output
57
+ - key: artifacts.catalogs.people
58
+ path: artifacts/catalogs/people.md
59
+ zone: artifacts
57
60
  schema: null
58
61
  owner: build:catalog-people
59
- projection:
60
- select: working.network.org # prefix or list of prefixes
62
+ compute:
63
+ kind: projection
64
+ select: knowledge.network.org # prefix or list of prefixes
61
65
  pluck: [name, relationship, org]
62
66
  sort_by: name
63
- template: people.mustache # under .textus/templates/
64
- publish_to: [docs/people.md] # optional repo-relative byte-copy targets
67
+ template: people.mustache # under .textus/templates/
68
+ publish_to: [docs/people.md] # optional repo-relative byte-copy targets
65
69
  ```
66
70
 
67
- **`generator:`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `textus freshness` can compare source mtimes against the file's `_meta.generated.at`.
71
+ **`compute: { kind: external }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `textus freshness` can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `build` (default: `automation`).
68
72
 
69
73
  ```yaml
70
- - key: output.catalogs.skills
71
- path: output/catalogs/skills.md
72
- zone: output
74
+ - key: artifacts.catalogs.skills
75
+ path: artifacts/catalogs/skills.md
76
+ zone: artifacts
73
77
  owner: build:catalog-skills
74
- generator:
75
- command: "rake catalog:skills" # informational; the runner invokes it
76
- sources: [working.projects, working.network]
78
+ compute:
79
+ kind: external
80
+ command: "rake catalog:skills" # informational; the automation invokes it
81
+ sources: [knowledge.projects, knowledge.network]
77
82
  ```
78
83
 
79
- The build runner is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `generator.sources` — same list, recorded twice so a diff proves what was consumed.
84
+ The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `compute.sources` — same list, recorded twice so a diff proves what was consumed.
80
85
 
81
- Full contract for both shapes is in [`../SPEC.md` §5.2 and §5.2.1](../SPEC.md). Reducers (`projection.reduce:`) and per-leaf publishing (`publish_each:`) are also covered there.
86
+ Full contract for both shapes is in [`../SPEC.md` §5.2.1 and §5.2.2](../SPEC.md). Transforms (`compute.transform:`) and per-leaf publishing (`publish_each:`) are also covered there.
82
87
 
83
88
  ## Intake and freshness
84
89
 
85
- External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler; refresh is on demand:
90
+ External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler; fetch is on demand:
86
91
 
87
92
  ```sh
88
- textus refresh intake.notion.roadmap --as=runner
89
- textus refresh-stale --zone=intake --as=runner # everything past its TTL
93
+ textus fetch feeds.notion.roadmap --as=automation
94
+ textus fetch stale --zone=feeds --as=automation # everything past its TTL
90
95
  ```
91
96
 
92
97
  Freshness budgets live in the top-level `rules:` block, matched by glob:
93
98
 
94
99
  ```yaml
95
100
  rules:
96
- - match: intake.notion.**
97
- refresh: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
101
+ - match: feeds.notion.**
102
+ fetch: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
98
103
  ```
99
104
 
100
- A typical scheduled-refresh integration shells the `refresh-stale` sweep itself:
105
+ A typical scheduled-fetch integration shells the `fetch stale` sweep itself:
101
106
 
102
107
  ```sh
103
- textus refresh-stale --zone=intake --as=runner # in cron / CI
108
+ textus fetch stale --zone=intake --as=automation # in cron / CI
104
109
  ```
105
110
 
106
111
  See [`./zones.md` §6](zones.md) for the full intake contract and [`./events.md`](events.md) for writing custom handlers.
107
112
 
108
- ### Read vs. refresh
113
+ ### Read vs. fetch
109
114
 
110
115
  There are two read operations, and the difference matters in custom code:
111
116
 
112
- | Operation | Triggers refresh? | Use for |
113
- |-----------|-------------------|---------|
117
+ | Operation | Triggers fetch? | Use for |
118
+ |-----------|-----------------|---------|
114
119
  | `ops.get` | No — pure read of on-disk envelope + freshness verdict | build / materialization paths, schema tooling, any context where a side-effecting read would surprise the caller |
115
- | `ops.get_or_refresh` | Yes — composes `get` with the orchestrator per the rule's `on_stale` policy | interactive reads (`textus get`, dashboards) where the caller wants the freshest envelope obtainable |
120
+ | `ops.get_or_fetch` | Yes — composes `get` with the orchestrator per the rule's `on_stale` policy | interactive reads (`textus get`, dashboards) where the caller wants the freshest envelope obtainable |
116
121
 
117
- Build always uses the pure path; injecting refresh into materialization caused the cascading-staleness incident behind issue #59. Pick `get_or_refresh` only when you genuinely want side effects on read.
122
+ Build always uses the pure path; injecting fetch into materialization caused the cascading-staleness incident behind issue #59. Pick `get_or_fetch` only when you genuinely want side effects on read.
118
123
 
119
124
  ## Body content
120
125
 
data/lib/textus/boot.rb CHANGED
@@ -8,46 +8,56 @@ module Textus
8
8
  module Boot
9
9
  PROTOCOL_ID = PROTOCOL
10
10
 
11
- # Conventional zone purposes. Unknown zones (declared in the manifest
12
- # but not listed here) get no `purpose` field.
13
- ZONE_PURPOSES = {
14
- "identity" => "slow-changing identity; human-only writes",
15
- "working" => "active project state; humans, AI, and scripts share this surface",
16
- "intake" => "declared external inputs; script-refreshed via actions",
17
- "review" => "AI proposals awaiting human accept",
18
- "output" => "build-computed outputs; never hand-edited",
19
- }.freeze
20
-
21
- # Per-kind write-flow templates. Each lambda receives the user-facing role
22
- # name and returns a guidance string for that role. Roles whose kind has
23
- # no template (e.g. unknown future kinds) are omitted from write_flows.
11
+ # Per-capability write-flow templates. Each lambda receives the user-facing
12
+ # role name and the manifest, and returns guidance for that verb with the
13
+ # live zone named by kind (ADR 0034). A role holding multiple verbs gets one
14
+ # joined string; roles whose verbs have no template are omitted.
24
15
  WRITE_FLOW_TEMPLATES = {
25
- accept_authority: lambda do |name, _manifest|
26
- "edit files in identity/working zones, then 'textus put KEY --as=#{name}'"
16
+ author: lambda do |name, manifest|
17
+ "edit files in #{zone_label(manifest, :canon, "your canon zone")}, " \
18
+ "then 'textus put KEY --as=#{name}'"
27
19
  end,
28
- proposer: lambda do |name, manifest|
29
- authority = manifest.policy.roles_with_kind(:accept_authority).first || "accept_authority"
30
- "propose changes by writing review.* entries with --as=#{name} and a 'proposal:' frontmatter block; " \
31
- "the #{authority} role runs 'textus accept' to apply"
20
+ keep: lambda do |name, manifest|
21
+ "keep durable notes in #{zone_label(manifest, :workspace, "your workspace")}: " \
22
+ "'textus put KEY --as=#{name}' (no accept needed)"
32
23
  end,
33
- runner: lambda do |name, _manifest|
34
- "refresh intake entries with 'textus refresh KEY --as=#{name}' (uses the entry's declared action)"
24
+ propose: lambda do |name, manifest|
25
+ authority = manifest.policy.roles_with_capability("author").first || "the author-holder"
26
+ "propose changes by writing #{manifest.policy.queue_zone}.* entries with --as=#{name} " \
27
+ "and a 'proposal:' frontmatter block; the #{authority} role runs 'textus accept' to apply"
35
28
  end,
36
- generator: lambda do |_name, _manifest|
37
- "'textus build' computes output entries from projections; output files are never hand-edited"
29
+ fetch: lambda do |name, manifest|
30
+ "fetch #{zone_label(manifest, :quarantine, "quarantine")} entries with " \
31
+ "'textus fetch KEY --as=#{name}' (uses the entry's declared action)"
32
+ end,
33
+ build: lambda do |_name, manifest|
34
+ derived = zone_label(manifest, :derived, "derived")
35
+ "'textus build' computes #{derived} entries from projections; " \
36
+ "#{derived} files are never hand-edited"
38
37
  end,
39
38
  }.freeze
40
39
 
41
40
  def self.write_flows_for(manifest)
42
- manifest.policy.role_mapping.each_with_object({}) do |(name, kind), acc|
43
- tmpl = WRITE_FLOW_TEMPLATES[kind]
44
- acc[name] = tmpl.call(name, manifest) if tmpl
41
+ manifest.data.role_caps.each_with_object({}) do |(name, caps), acc|
42
+ flows = caps.filter_map do |verb|
43
+ tmpl = WRITE_FLOW_TEMPLATES[verb.to_sym]
44
+ tmpl&.call(name, manifest)
45
+ end
46
+ acc[name] = flows.join(" / ") unless flows.empty?
45
47
  end
46
48
  end
47
49
 
50
+ # Human-readable name(s) for the live zone(s) of a given kind, or `fallback`
51
+ # when the manifest declares none. Lets write-flow guidance name the live
52
+ # zone by kind instead of a hardcoded instance name (ADR 0034).
53
+ def self.zone_label(manifest, kind, fallback)
54
+ zones = manifest.policy.zones_of_kind(kind)
55
+ zones.empty? ? fallback : zones.join(", ")
56
+ end
57
+
48
58
  # Static, store-independent parts of the agent-facing protocol. The
49
- # `role_resolution` block is derived per-manifest in agent_protocol(...)
50
- # because role names are user-configurable.
59
+ # `recipes` and `role_resolution` blocks are derived per-manifest in
60
+ # agent_protocol(...) because zone and role names are user-configurable.
51
61
  AGENT_PROTOCOL_TEMPLATE = {
52
62
  "envelope_shape" => {
53
63
  "summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
@@ -59,7 +69,76 @@ module Textus
59
69
  },
60
70
  "ref" => "SPEC.md §8",
61
71
  },
62
- "recipes" => {
72
+ }.freeze
73
+
74
+ # Curated agent-facing verb catalog. For verbs that have a Dispatcher contract,
75
+ # the summary is derived from `contract.summary` at load time (ADR 0039). The
76
+ # editorial strings below are the fallback for CLI-only verbs without contracts.
77
+ # CLI_VERBS itself is assigned in textus.rb after Zeitwerk eager_load so that
78
+ # all contract-declaring files are loaded before derivation runs.
79
+ CURATED_CLI_VERBS = [
80
+ { "name" => "boot" },
81
+ { "name" => "list" },
82
+ { "name" => "get" },
83
+ { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
84
+ { "name" => "schema" },
85
+ { "name" => "put" },
86
+ { "name" => "propose" },
87
+ { "name" => "accept", "summary" => "apply a queued proposal to its target zone; requires the author capability" },
88
+ { "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
89
+ { "name" => "delete", "summary" => "delete an entry; --as=<role>" },
90
+ { "name" => "build", "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
91
+ { "name" => "fetch" },
92
+ { "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
93
+ { "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
94
+ { "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
95
+ { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
96
+ { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
97
+ { "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
98
+ { "name" => "pulse" },
99
+ ].freeze
100
+
101
+ # Build the CLI verb catalog by deriving each summary from the corresponding
102
+ # Dispatcher contract when one exists, falling back to the editorial string for
103
+ # CLI-only verbs without a contract (e.g. accept, build, where). Called once
104
+ # from textus.rb after eager_load so all contract files are present.
105
+ def self.build_cli_verbs
106
+ by_contract = Dispatcher::VERBS.values
107
+ .select { |k| k.respond_to?(:contract?) && k.contract? }
108
+ .to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
109
+
110
+ CURATED_CLI_VERBS.map do |entry|
111
+ derived = by_contract[entry["name"]]
112
+ if derived
113
+ entry.merge("summary" => derived)
114
+ else
115
+ entry
116
+ end
117
+ end
118
+ end
119
+
120
+ def self.agent_quickstart(manifest, audit_log)
121
+ agent_role = manifest.policy.proposer_role
122
+
123
+ writable_zones = manifest.data.declared_zone_kinds.keys.each_with_object([]) do |zname, acc|
124
+ acc << zname if agent_role && manifest.policy.zone_writers(zname).include?(agent_role)
125
+ end
126
+
127
+ propose_zone = manifest.policy.propose_zone_for(agent_role)
128
+
129
+ {
130
+ "read_verbs" => %w[boot get list audit pulse freshness doctor],
131
+ "write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
132
+ "writable_zones" => writable_zones,
133
+ "propose_zone" => propose_zone,
134
+ "latest_seq" => audit_log.latest_seq,
135
+ }
136
+ end
137
+
138
+ def self.recipes(manifest)
139
+ queue = manifest.policy.queue_zone
140
+ feeds = zone_label(manifest, :quarantine, "the quarantine zone")
141
+ {
63
142
  "read" => {
64
143
  "purpose" => "find and read an entry",
65
144
  "steps" => [
@@ -78,73 +157,29 @@ module Textus
78
157
  "propose" => {
79
158
  "purpose" => "agent suggests a change for human review",
80
159
  "agent_steps" => [
81
- "echo ENVELOPE | textus put review.KEY --as=agent --stdin",
160
+ "echo ENVELOPE | textus put #{queue}.KEY --as=agent --stdin",
82
161
  ],
83
162
  "human_steps" => [
84
- "textus accept review.KEY --as=human # promotes the proposal to its target zone",
163
+ "textus accept #{queue}.KEY --as=human # promotes the proposal to its target zone",
85
164
  ],
86
165
  },
87
- "refresh" => {
88
- "purpose" => "rebuild stale intake-zone caches from their declared actions",
166
+ "fetch" => {
167
+ "purpose" => "rebuild stale quarantine-zone caches from their declared actions",
89
168
  "steps" => [
90
- "textus freshness --zone=intake # report fresh/stale per entry",
91
- "textus refresh stale --zone=intake --as=runner",
169
+ "textus freshness --zone=#{feeds} # report fresh/stale per entry",
170
+ "textus fetch stale --zone=#{feeds} --as=automation",
92
171
  ],
93
172
  },
94
- },
95
- }.freeze
96
-
97
- # The CLI verb catalog. Truth lives here; do not derive dynamically.
98
- # Agents that read boot should see a stable shape regardless of how
99
- # verb implementations evolve.
100
- CLI_VERBS = [
101
- { "name" => "boot", "summary" => "this output — orientation for agents and tools" },
102
- { "name" => "list", "summary" => "enumerate keys (optional --prefix)" },
103
- { "name" => "get", "summary" => "read an entry; envelope with _meta, body, uid, etag" },
104
- { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
105
- { "name" => "schema", "summary" => "field shape for a key family" },
106
- { "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
107
- { "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
108
- { "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
109
- { "name" => "delete", "summary" => "delete an entry; --as=<role>" },
110
- { "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
111
- { "name" => "refresh", "summary" => "run an action for an intake entry" },
112
- { "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
113
- { "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
114
- { "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
115
- { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
116
- { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
117
- { "name" => "hook",
118
- "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
119
- { "name" => "pulse",
120
- "summary" => "delta since cursor — changed entries, stale, pending review, doctor summary" },
121
- ].freeze
122
-
123
- def self.agent_quickstart(manifest, audit_log)
124
- proposer_roles = manifest.policy.roles_with_kind(:proposer)
125
- agent_role = proposer_roles.first
126
-
127
- writable_zones = manifest.data.zones.each_with_object([]) do |(zname, writers), acc|
128
- acc << zname if agent_role && writers.include?(agent_role)
129
- end
130
-
131
- propose_zone = manifest.policy.propose_zone_for(agent_role)
132
-
133
- {
134
- "read_verbs" => %w[boot get list audit pulse freshness doctor],
135
- "write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
136
- "writable_zones" => writable_zones,
137
- "propose_zone" => propose_zone,
138
- "latest_seq" => audit_log.latest_seq,
139
173
  }
140
174
  end
141
175
 
142
176
  def self.agent_protocol(manifest)
143
177
  AGENT_PROTOCOL_TEMPLATE.merge(
178
+ "recipes" => recipes(manifest),
144
179
  "role_resolution" => {
145
180
  "summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
146
- "default 'human'",
147
- "roles" => manifest.policy.role_mapping.keys,
181
+ "then a transport default ('human' for CLI, 'agent' for MCP)",
182
+ "roles" => manifest.data.role_caps.keys,
148
183
  "ref" => "SPEC.md §5",
149
184
  },
150
185
  )
@@ -162,17 +197,17 @@ module Textus
162
197
  "cli_verbs" => CLI_VERBS.map(&:dup),
163
198
  "agent_protocol" => agent_protocol(manifest),
164
199
  "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
165
- "docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
200
+ "docs" => { "spec" => "SPEC.md", "example" => "examples/project/" },
166
201
  }
167
202
  end
168
203
 
169
204
  def self.zones_for(manifest)
170
- manifest.data.zones.map do |name, writers|
171
- row = { "name" => name, "writers" => Array(writers) }
205
+ manifest.data.declared_zone_kinds.keys.map do |name|
206
+ row = { "name" => name, "writers" => manifest.policy.zone_writers(name) }
172
207
  kind = manifest.policy.declared_kind(name)
173
208
  row["kind"] = kind.to_s if kind
174
- purpose = ZONE_PURPOSES[name]
175
- row["purpose"] = purpose if purpose
209
+ purpose = manifest.data.zone_descs[name]
210
+ row["purpose"] = purpose if purpose && !purpose.empty?
176
211
  row
177
212
  end
178
213
  end
@@ -1,15 +1,15 @@
1
1
  module Textus
2
2
  class CLI
3
3
  class Group
4
- class Refresh < Group
5
- command_name "refresh"
4
+ class Fetch < Group
5
+ command_name "fetch"
6
6
 
7
7
  def parse(argv)
8
8
  if argv.first == "stale"
9
9
  argv.shift
10
- @sub_klass = Verb::RefreshStale
10
+ @sub_klass = Verb::FetchStale
11
11
  else
12
- @sub_klass = Verb::Refresh
12
+ @sub_klass = Verb::Fetch
13
13
  end
14
14
  @sub = @sub_klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
15
15
  @sub.parse(argv)
@@ -8,7 +8,7 @@ module Textus
8
8
 
9
9
  def call(store)
10
10
  Textus::Ports::BuildLock.with(root: store.root) do
11
- role = store.manifest.policy.roles_with_kind(:generator).first || "builder"
11
+ role = store.manifest.policy.roles_with_capability("build").first || "automation"
12
12
  ops = store.as(role)
13
13
  result = ops.publish(prefix: prefix)
14
14
  emit(result)
@@ -0,0 +1,14 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Fetch < Verb
5
+ option :as_flag, "--as=ROLE"
6
+
7
+ def call(store)
8
+ key = positional.shift or raise UsageError.new("fetch requires a key")
9
+ emit(session_for(store).fetch(key).to_h_for_wire)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -1,16 +1,16 @@
1
1
  module Textus
2
2
  class CLI
3
3
  class Verb
4
- class RefreshStale < Verb
4
+ class FetchStale < Verb
5
5
  command_name "stale"
6
- parent_group Group::Refresh
6
+ parent_group Group::Fetch
7
7
 
8
8
  option :prefix, "--prefix=KEY"
9
9
  option :zone, "--zone=Z"
10
10
  option :as_flag, "--as=ROLE"
11
11
 
12
12
  def call(store)
13
- result = session_for(store).refresh_all(prefix: prefix, zone: zone)
13
+ result = session_for(store).fetch_all(prefix: prefix, zone: zone)
14
14
  emit(result)
15
15
  result["ok"] ? 0 : 1
16
16
  end
@@ -8,7 +8,7 @@ module Textus
8
8
 
9
9
  def call(store)
10
10
  key = positional.shift or raise UsageError.new("get requires a key")
11
- result = session_for(store).get_or_refresh(key)
11
+ result = session_for(store).get_or_fetch(key)
12
12
  raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
13
13
 
14
14
  emit(result.to_h_for_wire)
@@ -35,7 +35,7 @@ module Textus
35
35
 
36
36
  rows << {
37
37
  "event" => evt.to_s, "mode" => "manifest", "exec" => defn["exec"],
38
- "key" => e.key, "as" => defn["as"] || "runner"
38
+ "key" => e.key, "as" => defn["as"] || "automation"
39
39
  }
40
40
  end
41
41
  end
@@ -1,14 +1,19 @@
1
1
  module Textus
2
2
  class CLI
3
3
  class Verb
4
- # Launches the MCP stdio server in the current process. Blocks on
5
- # stdin; never returns until stdin closes.
4
+ # Launches the MCP stdio server in the current process. Blocks on stdin;
5
+ # never returns until stdin closes. The connection acts as the `agent`
6
+ # role by default (ADR 0040): the agent channel proposes, it does not
7
+ # inherit the human's authority. Override per connection with --as, or
8
+ # TEXTUS_ROLE / .textus/role (same chain as every other verb).
6
9
  class MCPServe < Verb
7
10
  command_name "serve"
8
11
  parent_group Group::MCP
12
+ option :as_flag, "--as=ROLE"
9
13
 
10
14
  def call(store)
11
- Textus::MCP::Server.new(store: store, stdin: @stdin, stdout: @stdout).run
15
+ role = resolved_role(store, default: Textus::Role::AGENT)
16
+ Textus::MCP::Server.new(store: store, stdin: @stdin, stdout: @stdout, role: role).run
12
17
  0
13
18
  end
14
19
  end
@@ -0,0 +1,28 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ # Queue a proposal. Mirrors the MCP `propose` tool: resolves the
5
+ # manifest's propose_zone and prefixes the key, so the author does not
6
+ # need to know the queue zone's name. ADR 0036.
7
+ class Propose < Verb
8
+ command_name "propose"
9
+
10
+ option :as_flag, "--as=ROLE"
11
+ option :use_stdin, "--stdin"
12
+
13
+ def call(store)
14
+ rel = positional.shift or raise UsageError.new("propose requires a key")
15
+ raise UsageError.new("propose requires --stdin") unless use_stdin
16
+
17
+ payload = JSON.parse(@stdin.read)
18
+ env = store.as(resolved_role(store)).propose(
19
+ rel,
20
+ meta: payload["_meta"] || {},
21
+ body: payload["body"] || "",
22
+ )
23
+ emit(env.to_h_for_wire)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -4,12 +4,21 @@ module Textus
4
4
  class Pulse < Verb
5
5
  command_name "pulse"
6
6
 
7
+ option :as_flag, "--as=ROLE"
7
8
  option :since, "--since=N"
8
9
 
9
10
  def call(store)
10
- ops = session_for(store)
11
- since_n = (since || "0").to_i
12
- emit(ops.pulse(since: since_n))
11
+ role = resolved_role(store)
12
+ ops = store.as(role)
13
+
14
+ if since
15
+ emit(ops.pulse(since: since.to_i))
16
+ else
17
+ cursors = Textus::CursorStore.new(root: store.root, role: role)
18
+ result = ops.pulse(since: cursors.read)
19
+ cursors.write(result["cursor"])
20
+ emit(result)
21
+ end
13
22
  end
14
23
  end
15
24
  end
@@ -25,7 +25,7 @@ module Textus
25
25
  {
26
26
  "_meta" => {
27
27
  "name" => basename,
28
- "last_refreshed_at" => Time.now.utc.iso8601,
28
+ "last_fetched_at" => Time.now.utc.iso8601,
29
29
  "fetched_with" => fetch_name,
30
30
  }.merge(result[:_meta] || result["_meta"] || {}),
31
31
  "body" => result[:body] || result["body"] || "",
@@ -8,16 +8,16 @@ module Textus
8
8
  def call(store)
9
9
  policies = store.manifest.rules.blocks.map do |b|
10
10
  row = { "match" => b.match }
11
- if b.refresh
12
- row["refresh"] = {
13
- "ttl_seconds" => b.refresh.ttl_seconds,
14
- "on_stale" => b.refresh.on_stale,
15
- "sync_budget_ms" => b.refresh.sync_budget_ms,
16
- "fetch_timeout_seconds" => b.refresh.fetch_timeout_seconds,
11
+ if b.fetch
12
+ row["fetch"] = {
13
+ "ttl_seconds" => b.fetch.ttl_seconds,
14
+ "on_stale" => b.fetch.on_stale,
15
+ "sync_budget_ms" => b.fetch.sync_budget_ms,
16
+ "fetch_timeout_seconds" => b.fetch.fetch_timeout_seconds,
17
17
  }
18
18
  end
19
19
  row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
20
- row["promotion"] = { "requires" => b.promote.requires } if b.promote
20
+ row["guard"] = b.guard if b.guard
21
21
  row["retention"] = { "expire_after" => b.retention.expire_after, "archive_after" => b.retention.archive_after } if b.retention
22
22
  row
23
23
  end
@@ -7,7 +7,7 @@ module Textus
7
7
 
8
8
  def call(store)
9
9
  key = positional.shift or raise UsageError.new("schema requires a key")
10
- emit(session_for(store).schema_envelope(key))
10
+ emit(session_for(store).schema(key))
11
11
  end
12
12
  end
13
13
  end
@@ -91,9 +91,10 @@ module Textus
91
91
 
92
92
  # Resolves the active role for this invocation. Honors the verb's
93
93
  # `--as` flag if declared, then TEXTUS_ROLE, then the project default.
94
- def resolved_role(store)
94
+ # Pass `default:` to override the fallback (e.g. MCPServe uses AGENT).
95
+ def resolved_role(store, default: Role::DEFAULT)
95
96
  flag = respond_to?(:as_flag) ? as_flag : nil
96
- Role.resolve(flag: flag, env: ENV, root: store.root)
97
+ Role.resolve(flag: flag, env: ENV, root: store.root, default: default)
97
98
  end
98
99
 
99
100
  # Returns a Call value bound to the resolved role. Convenience for