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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +2 -241
- data/CHANGELOG.md +221 -0
- data/README.md +89 -69
- data/SPEC.md +359 -212
- data/docs/conventions.md +42 -37
- data/lib/textus/boot.rb +122 -87
- data/lib/textus/cli/group/{refresh.rb → fetch.rb} +4 -4
- data/lib/textus/cli/verb/build.rb +1 -1
- data/lib/textus/cli/verb/fetch.rb +14 -0
- data/lib/textus/cli/verb/{refresh_stale.rb → fetch_stale.rb} +3 -3
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hooks.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +8 -3
- data/lib/textus/cli/verb/propose.rb +28 -0
- data/lib/textus/cli/verb/pulse.rb +12 -3
- data/lib/textus/cli/verb/put.rb +1 -1
- data/lib/textus/cli/verb/rule_list.rb +7 -7
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb.rb +3 -2
- data/lib/textus/cli.rb +2 -2
- data/lib/textus/container.rb +1 -2
- data/lib/textus/contract.rb +106 -0
- data/lib/textus/cursor_store.rb +24 -0
- data/lib/textus/dispatcher.rb +6 -4
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +8 -8
- data/lib/textus/doctor/check/proposal_targets.rb +45 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor.rb +2 -1
- data/lib/textus/domain/action.rb +3 -3
- data/lib/textus/domain/freshness/evaluator.rb +3 -3
- data/lib/textus/domain/freshness/policy.rb +2 -2
- data/lib/textus/domain/freshness.rb +7 -7
- data/lib/textus/domain/outcome.rb +2 -2
- data/lib/textus/domain/permission.rb +2 -10
- data/lib/textus/domain/policy/base_guards.rb +25 -0
- data/lib/textus/domain/policy/evaluation.rb +15 -0
- data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +1 -1
- data/lib/textus/domain/policy/guard.rb +35 -0
- data/lib/textus/domain/policy/guard_factory.rb +40 -0
- data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
- data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
- data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
- data/lib/textus/domain/policy/predicates/registry.rb +39 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
- data/lib/textus/domain/staleness/intake_check.rb +6 -6
- data/lib/textus/envelope.rb +2 -2
- data/lib/textus/errors.rb +25 -28
- data/lib/textus/hooks/event_bus.rb +4 -4
- data/lib/textus/init.rb +27 -18
- data/lib/textus/layout.rb +41 -0
- data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
- data/lib/textus/maintenance/migrate.rb +9 -0
- data/lib/textus/maintenance/rule_lint.rb +8 -0
- data/lib/textus/maintenance/zone_mv.rb +11 -1
- data/lib/textus/manifest/capabilities.rb +29 -0
- data/lib/textus/manifest/data.rb +14 -10
- data/lib/textus/manifest/policy.rb +37 -21
- data/lib/textus/manifest/rules.rb +16 -14
- data/lib/textus/manifest/schema.rb +48 -58
- data/lib/textus/manifest.rb +3 -3
- data/lib/textus/mcp/catalog.rb +72 -0
- data/lib/textus/mcp/server.rb +8 -5
- data/lib/textus/mcp/session.rb +3 -20
- data/lib/textus/mcp/tool_schemas.rb +6 -62
- data/lib/textus/mcp/tools.rb +4 -119
- data/lib/textus/ports/audit_log.rb +17 -15
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/build_lock.rb +1 -2
- data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
- data/lib/textus/ports/{refresh → fetch}/lock.rb +2 -2
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/read/audit.rb +3 -3
- data/lib/textus/read/boot.rb +6 -0
- data/lib/textus/read/freshness.rb +9 -9
- data/lib/textus/read/get.rb +16 -8
- data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
- data/lib/textus/read/list.rb +8 -0
- data/lib/textus/read/policy_explain.rb +14 -10
- data/lib/textus/read/pulse.rb +12 -4
- data/lib/textus/read/rules.rb +24 -0
- data/lib/textus/read/schema_envelope.rb +7 -0
- data/lib/textus/read/validator.rb +1 -1
- data/lib/textus/role.rb +6 -2
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/session.rb +24 -0
- data/lib/textus/store.rb +11 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +19 -55
- data/lib/textus/write/delete.rb +14 -2
- data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +14 -6
- data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
- data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +29 -14
- data/lib/textus/write/mv.rb +15 -3
- data/lib/textus/write/propose.rb +46 -0
- data/lib/textus/write/put.rb +26 -2
- data/lib/textus/write/reject.rb +11 -5
- data/lib/textus.rb +4 -0
- metadata +36 -21
- data/lib/textus/cli/verb/refresh.rb +0 -14
- data/lib/textus/domain/authorizer.rb +0 -37
- data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
- data/lib/textus/domain/policy/promote.rb +0 -26
- data/lib/textus/domain/policy/promotion.rb +0 -57
- data/lib/textus/manifest/role_kinds.rb +0 -21
- data/lib/textus/write/authority_gate.rb +0 -24
data/docs/conventions.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Conventions
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
knowledge/ # authored truth: identity, voice, decisions — author-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 `
|
|
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
|
-
|
|
52
|
+
A derived entry declares a `compute:` block with a `kind:` discriminator. Two kinds:
|
|
50
53
|
|
|
51
|
-
**`projection
|
|
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:
|
|
55
|
-
path:
|
|
56
|
-
zone:
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
64
|
-
publish_to: [docs/people.md]
|
|
67
|
+
template: people.mustache # under .textus/templates/
|
|
68
|
+
publish_to: [docs/people.md] # optional repo-relative byte-copy targets
|
|
65
69
|
```
|
|
66
70
|
|
|
67
|
-
**`
|
|
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:
|
|
71
|
-
path:
|
|
72
|
-
zone:
|
|
74
|
+
- key: artifacts.catalogs.skills
|
|
75
|
+
path: artifacts/catalogs/skills.md
|
|
76
|
+
zone: artifacts
|
|
73
77
|
owner: build:catalog-skills
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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.
|
|
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;
|
|
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
|
|
89
|
-
textus
|
|
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:
|
|
97
|
-
|
|
101
|
+
- match: feeds.notion.**
|
|
102
|
+
fetch: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
|
|
98
103
|
```
|
|
99
104
|
|
|
100
|
-
A typical scheduled-
|
|
105
|
+
A typical scheduled-fetch integration shells the `fetch stale` sweep itself:
|
|
101
106
|
|
|
102
107
|
```sh
|
|
103
|
-
textus
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
26
|
-
"edit files in
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
"
|
|
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.
|
|
43
|
-
|
|
44
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
|
160
|
+
"echo ENVELOPE | textus put #{queue}.KEY --as=agent --stdin",
|
|
82
161
|
],
|
|
83
162
|
"human_steps" => [
|
|
84
|
-
"textus accept
|
|
163
|
+
"textus accept #{queue}.KEY --as=human # promotes the proposal to its target zone",
|
|
85
164
|
],
|
|
86
165
|
},
|
|
87
|
-
"
|
|
88
|
-
"purpose" => "rebuild stale
|
|
166
|
+
"fetch" => {
|
|
167
|
+
"purpose" => "rebuild stale quarantine-zone caches from their declared actions",
|
|
89
168
|
"steps" => [
|
|
90
|
-
"textus freshness --zone
|
|
91
|
-
"textus
|
|
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.
|
|
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/
|
|
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.
|
|
171
|
-
row = { "name" => name, "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 =
|
|
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
|
|
5
|
-
command_name "
|
|
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::
|
|
10
|
+
@sub_klass = Verb::FetchStale
|
|
11
11
|
else
|
|
12
|
-
@sub_klass = Verb::
|
|
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.
|
|
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
|
|
4
|
+
class FetchStale < Verb
|
|
5
5
|
command_name "stale"
|
|
6
|
-
parent_group Group::
|
|
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).
|
|
13
|
+
result = session_for(store).fetch_all(prefix: prefix, zone: zone)
|
|
14
14
|
emit(result)
|
|
15
15
|
result["ok"] ? 0 : 1
|
|
16
16
|
end
|
data/lib/textus/cli/verb/get.rb
CHANGED
|
@@ -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).
|
|
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)
|
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
data/lib/textus/cli/verb/put.rb
CHANGED
|
@@ -25,7 +25,7 @@ module Textus
|
|
|
25
25
|
{
|
|
26
26
|
"_meta" => {
|
|
27
27
|
"name" => basename,
|
|
28
|
-
"
|
|
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.
|
|
12
|
-
row["
|
|
13
|
-
"ttl_seconds" => b.
|
|
14
|
-
"on_stale" => b.
|
|
15
|
-
"sync_budget_ms" => b.
|
|
16
|
-
"fetch_timeout_seconds" => b.
|
|
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["
|
|
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
|
data/lib/textus/cli/verb.rb
CHANGED
|
@@ -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
|
-
|
|
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
|