textus 0.46.0 → 0.47.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd3d64e647d112827056119d45576eda3b7f6ba95446f7a08522d674fb496d2c
4
- data.tar.gz: b8c7c4d86eccd1469bdd024732dc0321fb64d837f59451efe1280fd7ae8d96e2
3
+ metadata.gz: c87fe067b0988d187beac617c4540e1563d0f006a08d6090b2b483c699555d46
4
+ data.tar.gz: 4f0503119ebf3eeff28d7a16aee8dc8c62673e6b41912e85bc3453e596075e65
5
5
  SHA512:
6
- metadata.gz: 10096cbffbd633d18a915744ddc4e70b7324ad955525a76fc9fb4b95b32ab8b0781d566e10d1fc6695dd5a795b4721926859030e4c86e661bdef1728304b62aa
7
- data.tar.gz: d393eee10185c057a7fe727dbb9abb572761e12bcec4b5253cda9a5367d5cd3684850e87432079cd71296f2ff917bd4e1a3e1be5b6484cc7be5ff3c33b4611a0
6
+ metadata.gz: fde04b0339e798e545c98699462afaf2767a772f1e76811c4f8833b561b69d02b172e4879cf17ffaea1dd51f1930846007abf4523a801ccfb5525c53e1365b7f
7
+ data.tar.gz: 10a4343781cb2251ba8f91121c0a89b1fe211702e7c5057693fce27357eee55c3abe6bfe7a88009ffc89f5fcc49ad6c44fcf258f532f5ac9d69d960857e833c6
data/CHANGELOG.md CHANGED
@@ -9,6 +9,22 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
9
9
  bump is a breaking change that requires a store migration; the gem version
10
10
  tracks both additive improvements and breaking protocol bumps independently.
11
11
 
12
+ ## 0.47.0 — 2026-06-04 — Close the agent loop over MCP
13
+
14
+ The edit→accept→rebuild loop now closes over a single MCP transport: `build` is surfaced to MCP, `init --with-agent` scaffolds a connectable agent setup, and connection-lifecycle hardening (whole-contract drift fingerprint + a connect-time event carrying the resolved role) underpins it.
15
+
16
+ ### Changed (breaking)
17
+
18
+ - **Drift guard now fingerprints the whole contract.** `Session#manifest_etag` is renamed `contract_etag` and now digests `manifest.yaml` + `hooks/**/*.rb` + `schemas/**/*` ([ADR 0074](docs/architecture/decisions/0074-contract-etag-drift-guard.md)). A mid-session edit to any hook or schema raises `contract_drift` on the next MCP `tools/call`, where previously only a manifest edit did. The composite digest lives as `Etag.for_contract`.
19
+ - **Wire:** the `pulse` envelope key `manifest_etag` is renamed `contract_etag`.
20
+ - **Ruby:** `Textus::Session#manifest_etag` / `Textus::MCP::Session#manifest_etag` is renamed `contract_etag`. Embedders constructing a `Session` must pass `contract_etag:`.
21
+
22
+ ### Added
23
+
24
+ - **`build` is surfaced to MCP** ([ADR 0076](docs/architecture/decisions/0076-build-gates-by-capability-actor-surface-to-mcp.md)) — previously CLI-only. `build` is transport-uniform, caller-agnostic, and self-elevating: it always runs as the manifest's `build`-capable actor (not the caller) and grants no authority over content, since it only recomputes the deterministic, content-addressed projection of already-accepted canon. Actor-resolution and the `BuildLock` moved out of the CLI verb into the shared `Write::Build` use-case (an `around :build_lock` resource), so single-writer serialization now spans **every** transport — closing a latent gap where the Ruby API path skipped the lock. The MCP catalog derives the new tool; `boot` auto-advertises it.
25
+ - **`textus init --with-agent`** ([ADR 0077](docs/architecture/decisions/0077-init-with-agent-profile.md)) — an opt-in profile that scaffolds a connectable agent setup on top of the neutral store: `knowledge.project` + `knowledge.runbooks` entries (with their schemas), an `artifacts.orientation` derived entry that projects them to `CLAUDE.md`/`AGENTS.md`, and a write-once starter `.mcp.json` at the project root. The default `init` (no flag) is unchanged and stays vendor-neutral; under the flag, `init` writes exactly one file outside `.textus/` (`.mcp.json`, never clobbered if present). Paired with `build` over MCP, an agent can edit `knowledge.*` and rebuild its own orientation without leaving the conversation.
26
+ - **`:session_opened` hook event** ([ADR 0075](docs/architecture/decisions/0075-session-opened-connect-event.md)) — fires once per MCP connection at `initialize` with `ctx:, role:, cursor:` (the resolved connection role). Use it for connect-time, role-keyed behavior (session logging, context priming). Distinct from `:store_loaded` (process-time, default role).
27
+
12
28
  ## 0.46.0 — 2026-06-03 — Container is the single source of truth
13
29
 
14
30
  No `textus/3` wire-format change. Internal refactor of the composition root. The 7-field capability set (`manifest, file_store, schemas, root, audit_log, events, rpc`) was previously spelled out four times — `Container`'s `Data.define`, `Store`'s ivar assignments, `Store`'s `attr_reader`s, and `Container.from_store`. It now lives in exactly one place.
data/SPEC.md CHANGED
@@ -653,6 +653,7 @@ end
653
653
  | `:entry_renamed` | pubsub | ctx:, key:, from_key:, to_key:, envelope: | (discarded) | logged |
654
654
  | `:proposal_rejected` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
655
655
  | `:store_loaded` | pubsub | ctx: | (discarded) | logged |
656
+ | `:session_opened` | pubsub | ctx:, role:, cursor: | (discarded) | logged |
656
657
  | `:fetch_started` | pubsub | ctx:, key:, mode: | (discarded) | logged |
657
658
  | `:fetch_failed` | pubsub | ctx:, key:, error_class:, error_message: | (discarded) | logged |
658
659
  | `:fetch_backgrounded` | pubsub | ctx:, key:, started_at:, budget_ms: | (discarded) | logged |
@@ -672,7 +673,7 @@ The three `:fetch_*` lifecycle events report the progress and failures of backgr
672
673
 
673
674
  Declaring `store:` instead of `caps:` in an RPC callable will pass registration but raise `UsageError` at call time (`Hooks::RpcRegistry#invoke` rejects `store:` — there is no shim).
674
675
 
675
- The primary entity is always `key:` (for `:proposal_accepted`, `key:` is the pending key being accepted and `target_key:` is the destination). For `:entry_renamed`, `key:` is present and equals `to_key:` — it is the entry's post-move home, present so `keys:` glob filters route correctly; `from_key:` is the prior key. For `:proposal_rejected`, `key:` is the pending key being rejected. For `:store_loaded`, no key — the event observes store readiness, not an entry.
676
+ The primary entity is always `key:` (for `:proposal_accepted`, `key:` is the pending key being accepted and `target_key:` is the destination). For `:entry_renamed`, `key:` is present and equals `to_key:` — it is the entry's post-move home, present so `keys:` glob filters route correctly; `from_key:` is the prior key. For `:proposal_rejected`, `key:` is the pending key being rejected. For `:store_loaded`, no key — the event observes store readiness, not an entry. For `:session_opened`, no key — it fires once per MCP connection at `initialize` with the connection's resolved `role:` and boot `cursor:` (ADR 0075); distinct from `:store_loaded`, which fires once per process at `Store#initialize` under the default role.
676
677
 
677
678
  **RPC mode** — exactly one handler per (event, name). The manifest references the handler by name (`intake.handler: NAME`, `compute.transform: NAME`). Failure or timeout aborts the calling operation.
678
679
 
@@ -909,7 +910,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
909
910
 
910
911
  `read_verbs` is derived from the MCP verb catalog — the verbs the agent can actually call over its transport — so it lists the read/discovery verbs (`schema_show` for an entry's field shape, `rule_explain` for its freshness/guard policy, and the graph reads `where`/`deps`/`rdeps`, ADR 0060) and never the CLI-only `audit`/`freshness`/`doctor` (ADR 0056). An agent learns an entry's `_meta` shape by calling the `schema_show` verb before a `put`/`propose`, not by shelling out to a CLI. The graph reads `deps`/`rdeps` return a structured `{key, deps}`/`{key, rdeps}` envelope on every surface (CLI, Ruby, MCP) — a hash, not a bare array, consistent with the other structured read responses such as `where` (ADR 0060 amendment).
911
912
 
912
- The agent's MCP write surface includes the single-key `delete` and `mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment). All of these apply by default; `dry_run: true` is a uniform opt-in preview that returns a Plan without mutating (ADR 0071 — verbs are actions, dry-run is opt-in on every surface). Single-key `delete` additionally accepts an optional `if_etag` optimistic-concurrency check. The blast-radius reads (`where`/`deps`/`rdeps`) remain on MCP so an agent can look before it leaps. The promotion verbs `accept` and `reject` are also on MCP (ADR 0072): they are gated by the `author_held` capability floor, not by transport absence — a default-`agent` connection is refused, while a connection launched as a role holding `author` (`--as`/`TEXTUS_ROLE`/`.textus/role`, resolved once at launch per ADR 0040) can promote, closing the propose→accept loop over one transport.
913
+ The agent's MCP write surface includes the single-key `delete` and `mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment). All of these apply by default; `dry_run: true` is a uniform opt-in preview that returns a Plan without mutating (ADR 0071 — verbs are actions, dry-run is opt-in on every surface). Single-key `delete` additionally accepts an optional `if_etag` optimistic-concurrency check. The blast-radius reads (`where`/`deps`/`rdeps`) remain on MCP so an agent can look before it leaps. The promotion verbs `accept` and `reject` are also on MCP (ADR 0072): they are gated by the `author_held` capability floor, not by transport absence — a default-`agent` connection is refused, while a connection launched as a role holding `author` (`--as`/`TEXTUS_ROLE`/`.textus/role`, resolved once at launch per ADR 0040) can promote, closing the propose→accept loop over one transport. `build` is also on MCP (ADR 0076): it is caller-agnostic and self-elevating — it always runs as the manifest's `build`-capable actor regardless of the calling role, grants no authority over content (build is a pure, idempotent function of already-accepted canon, ADR 0070), and is serialized by a shared single-writer lock across all transports so a concurrent CLI or background build cannot collide with an MCP-triggered one.
913
914
 
914
915
  `latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
915
916
 
@@ -922,13 +923,13 @@ The agent's MCP write surface includes the single-key `delete` and `mv` tools al
922
923
  "stale": [ "artifacts.marketplace" ],
923
924
  "pending_review": [ "proposals.proposal.123" ],
924
925
  "doctor": { "ok": true, "warn": 0, "fail": 0 },
925
- "manifest_etag": "sha256:1f3a…",
926
+ "contract_etag": "sha256:1f3a…",
926
927
  "next_due_at": "2026-06-01T09:00:00Z",
927
928
  "hook_errors": [ { "seq": 1844, "event": "after_put", "hook": "notify", "key": "knowledge.notes.x", "error_class": "Timeout::Error", "error_message": "…", "at": "..." } ]
928
929
  }
929
930
  ```
930
931
 
931
- `cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `stale` is sourced from `freshness`. `pending_review` lists all keys in the queue zone. `doctor` is an `{ok, warn, fail}` count summary. `manifest_etag` is the `sha256:`-prefixed content hash of the manifest (ADR 0025), for cheap change-detection. `next_due_at` is the soonest upcoming freshness deadline across entries (ISO-8601, or `null` if none). `hook_errors` lists hook failures recorded since the cursor. When `--since` is below the oldest available seq (due to audit log rotation), pulse returns `CursorExpired`.
932
+ `cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `stale` is sourced from `freshness`. `pending_review` lists all keys in the queue zone. `doctor` is an `{ok, warn, fail}` count summary. `contract_etag` is the `sha256:`-prefixed composite content hash of the contract — the manifest plus hooks and schemas (ADR 0074, via ADR 0025) for cheap change-detection. `next_due_at` is the soonest upcoming freshness deadline across entries (ISO-8601, or `null` if none). `hook_errors` lists hook failures recorded since the cursor. When `--since` is below the oldest available seq (due to audit log rotation), pulse returns `CursorExpired`.
932
933
 
933
934
  **`put` input** (read from stdin when `--stdin` is given):
934
935
 
@@ -237,7 +237,7 @@ soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Store ──▶ me
237
237
  Two transports, one façade:
238
238
 
239
239
  - **CLI** — human/script surface. `textus boot`, `textus pulse --since=N`, `textus get/put/...`.
240
- - **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. Tools are auto-derived from the manifest. Session state (cursor, role, manifest_etag) is server-side.
240
+ - **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. Tools are auto-derived from the manifest. Session state (cursor, role, contract_etag) is server-side.
241
241
 
242
242
  Both transports call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`). No duplicate logic.
243
243
 
@@ -247,7 +247,7 @@ The agent loop (cadence guide in [`agents-mcp.md`](../how-to/agents-mcp.md)):
247
247
  2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor}`.
248
248
  3. **On demand:** `get`, `put`, `propose`, `fetch`, `schema_show`, `rule_explain`.
249
249
 
250
- Manifest drift surfaces as `ContractDrift` (manifest_etag mismatch); audit cursor falls off the keep window as `CursorExpired`. Both signal "call `boot` again."
250
+ Contract drift surfaces as `ContractDrift` (contract_etag mismatch — a change to the manifest, hooks, or schemas; ADR 0074); audit cursor falls off the keep window as `CursorExpired`. Both signal "call `boot` again."
251
251
 
252
252
  ## Hooks event catalog
253
253
 
@@ -7,16 +7,7 @@ module Textus
7
7
  option :prefix, "--prefix=K"
8
8
 
9
9
  def invoke(store)
10
- role = store.manifest.policy.actor_for("build") or
11
- raise UsageError.new(
12
- "no role holds the 'build' capability",
13
- hint: "declare a role with `can: [build]` in .textus/manifest.yaml",
14
- )
15
- Textus::Ports::BuildLock.with(root: store.root) do
16
- ops = store.as(role)
17
- result = ops.build(prefix: prefix)
18
- emit(result)
19
- end
10
+ emit(store.as(resolved_role(store)).build(prefix: prefix))
20
11
  end
21
12
  end
22
13
  end
@@ -4,11 +4,13 @@ module Textus
4
4
  class Init < Verb
5
5
  command_name "init"
6
6
 
7
+ option :with_agent, "--with-agent"
8
+
7
9
  def self.needs_store? = false
8
10
 
9
11
  def call(_store)
10
12
  target = File.join(@cwd, ".textus")
11
- emit(Textus::Init.run(target))
13
+ emit(Textus::Init.run(target, with_agent: !!with_agent))
12
14
  end
13
15
  end
14
16
  end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Contract
3
+ module Resources
4
+ # Serializes builds across every surface (CLI, MCP, Ruby). Previously the
5
+ # CLI verb wrapped each build in a BuildLock by hand; lifting it into the
6
+ # contract means the MCP surface inherits the single-writer guarantee and
7
+ # cannot collide with a concurrent CLI or background build.
8
+ class BuildLock
9
+ def wrap(scope:, inputs:, session: nil) # rubocop:disable Lint/UnusedMethodArgument
10
+ Textus::Ports::BuildLock.with(root: scope.container.root) { yield(inputs) }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ Textus::Contract::Around.register(:build_lock, Textus::Contract::Resources::BuildLock.new)
data/lib/textus/etag.rb CHANGED
@@ -9,5 +9,28 @@ module Textus
9
9
  def self.for_file(path)
10
10
  for_bytes(File.binread(path))
11
11
  end
12
+
13
+ # The fingerprint of everything an agent's boot orientation depends on:
14
+ # the manifest PLUS the executable contract — hooks and schemas. A
15
+ # mid-session edit to any of these makes the cached orientation stale, so
16
+ # the session must re-boot (ADR 0074). The composite is one digest over the
17
+ # sorted per-file listing, so it is order-stable.
18
+ def self.for_contract(root)
19
+ listing = contract_files(root).map do |path|
20
+ rel = path.delete_prefix(root).delete_prefix("/")
21
+ "#{rel}:#{for_file(path)}"
22
+ end.join("\n")
23
+ for_bytes(listing)
24
+ end
25
+
26
+ # manifest.yaml, then every hook and schema file. Dir.glob already returns
27
+ # sorted paths (Ruby 3.0+), keeping the digest independent of FS order.
28
+ def self.contract_files(root)
29
+ [
30
+ File.join(root, "manifest.yaml"),
31
+ *Dir.glob(File.join(root, "hooks", "**", "*.rb")),
32
+ *Dir.glob(File.join(root, "schemas", "**", "*")).select { |f| File.file?(f) },
33
+ ]
34
+ end
12
35
  end
13
36
  end
@@ -20,6 +20,7 @@ module Textus
20
20
  proposal_rejected: %i[ctx key target_key],
21
21
  file_published: %i[ctx key envelope source target],
22
22
  store_loaded: %i[ctx],
23
+ session_opened: %i[ctx role cursor],
23
24
  fetch_started: %i[ctx key mode],
24
25
  fetch_failed: %i[ctx key error_class error_message],
25
26
  fetch_backgrounded: %i[ctx key started_at budget_ms],
@@ -0,0 +1,17 @@
1
+ # Reducer that reshapes the raw projection rows into the keys the
2
+ # orientation.mustache template references. Without this, the template
3
+ # would only have access to the flat rows list.
4
+ Textus.hook do |reg|
5
+ reg.on(:transform_rows, :orientation_reducer) do |rows:, **|
6
+ project_row = rows.find { |r| r["_key"] == "knowledge.project" } || {}
7
+ runbook_rows = rows.select { |r| r["_key"]&.start_with?("knowledge.runbooks.") }
8
+
9
+ {
10
+ "project" => {
11
+ "name" => project_row["name"],
12
+ "description" => project_row["description"]
13
+ },
14
+ "runbooks" => runbook_rows.map { |r| { "name" => r["name"], "description" => r["description"] } }
15
+ }
16
+ end
17
+ end
data/lib/textus/init.rb CHANGED
@@ -94,13 +94,36 @@ module Textus
94
94
  Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
95
95
  :entry_put, :entry_deleted, :entry_fetched, :entry_renamed,
96
96
  :build_completed, :proposal_accepted, :proposal_rejected,
97
- :file_published, :store_loaded,
97
+ :file_published, :store_loaded, :session_opened,
98
98
  :fetch_started, :fetch_failed, :fetch_backgrounded (pub-sub — return discarded)
99
99
 
100
100
  See SPEC.md §5.10 for the full table.
101
101
  MD
102
102
 
103
- def self.run(target_root)
103
+ AGENT_ENTRIES = <<~YAML.gsub(/^/, " ")
104
+ # --with-agent profile: project facts + runbooks feed the orientation
105
+ # projection below, which `textus build` renders to CLAUDE.md/AGENTS.md.
106
+ - { key: knowledge.project, path: knowledge/project.md, zone: knowledge, schema: project, owner: human:self, kind: leaf }
107
+ - { key: knowledge.runbooks, path: knowledge/runbooks, zone: knowledge, schema: runbook, owner: human:self, nested: true, kind: nested }
108
+ - key: artifacts.orientation
109
+ path: artifacts/orientation.md
110
+ zone: artifacts
111
+ template: orientation.mustache
112
+ inject_boot: true
113
+ publish:
114
+ to:
115
+ - CLAUDE.md
116
+ - AGENTS.md
117
+ compute:
118
+ kind: projection
119
+ select:
120
+ - knowledge.project
121
+ - knowledge.runbooks
122
+ transform: orientation_reducer
123
+ kind: derived
124
+ YAML
125
+
126
+ def self.run(target_root, with_agent: false)
104
127
  raise UsageError.new(".textus/ already exists at #{target_root}") if File.directory?(target_root)
105
128
 
106
129
  FileUtils.mkdir_p(File.join(target_root, "schemas"))
@@ -115,12 +138,52 @@ module Textus
115
138
  scaffold_dir = File.expand_path("init/templates", __dir__)
116
139
  File.write(File.join(target_root, "hooks", "machine_intake.rb"),
117
140
  File.read(File.join(scaffold_dir, "machine_intake.rb")))
118
- File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
141
+ File.write(File.join(target_root, "manifest.yaml"), manifest_yaml(with_agent: with_agent))
142
+ mcp_status = nil
143
+ if with_agent
144
+ scaffold_agent_profile(target_root, scaffold_dir)
145
+ mcp_status = write_mcp_config(target_root, scaffold_dir)
146
+ end
119
147
  FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
120
148
  FileUtils.mkdir_p(Textus::Layout.state(target_root))
121
149
  FileUtils.mkdir_p(Textus::Layout.locks(target_root))
122
150
  File.write(File.join(target_root, ".gitignore"), derived_gitignore(target_root))
123
- { "protocol" => PROTOCOL, "initialized" => target_root }
151
+ result = { "protocol" => PROTOCOL, "initialized" => target_root, "profile" => with_agent ? "agent" : "default" }
152
+ result["mcp_config"] = mcp_status if with_agent
153
+ result
154
+ end
155
+
156
+ # Composes the agent profile by inserting AGENT_ENTRIES immediately before the
157
+ # top-level `rules:` block of DEFAULT_MANIFEST — that block is load-bearing for
158
+ # this `.sub`; removing it from DEFAULT_MANIFEST would silently drop the entries.
159
+ def self.manifest_yaml(with_agent:)
160
+ return DEFAULT_MANIFEST unless with_agent
161
+
162
+ DEFAULT_MANIFEST.sub(/^rules:/, "#{AGENT_ENTRIES}rules:")
163
+ end
164
+
165
+ # Copies the proven orientation bundle into a freshly-init'd store.
166
+ def self.scaffold_agent_profile(target_root, scaffold_dir)
167
+ {
168
+ "project.schema.yaml" => File.join("schemas", "project.yaml"),
169
+ "runbook.schema.yaml" => File.join("schemas", "runbook.yaml"),
170
+ "orientation.mustache" => File.join("templates", "orientation.mustache"),
171
+ "orientation_reducer.rb" => File.join("hooks", "orientation_reducer.rb"),
172
+ }.each do |src, dest|
173
+ File.write(File.join(target_root, dest), File.read(File.join(scaffold_dir, src)))
174
+ end
175
+ end
176
+
177
+ # The one file init writes outside .textus/: a starter .mcp.json at the
178
+ # project root. Write-once — never clobber a hand-authored config. The
179
+ # command form assumes a gem-installed `textus` on PATH; the user owns
180
+ # the file after this first write.
181
+ def self.write_mcp_config(target_root, scaffold_dir)
182
+ dest = File.join(File.dirname(target_root), ".mcp.json")
183
+ return "skipped" if File.exist?(dest)
184
+
185
+ File.write(dest, File.read(File.join(scaffold_dir, "mcp.json")))
186
+ "written"
124
187
  end
125
188
 
126
189
  # The store's `.gitignore` is generated, never hand-kept (ADR 0038), and now
@@ -59,7 +59,17 @@ module Textus
59
59
  role: @role,
60
60
  cursor: @store.audit_log.latest_seq,
61
61
  propose_zone: propose_zone,
62
- manifest_etag: manifest_etag,
62
+ contract_etag: contract_etag,
63
+ )
64
+
65
+ # ADR 0075: announce the connection to connect-time hooks with the
66
+ # resolved role. Distinct from :store_loaded (fired at Store.new under
67
+ # the default role, before any connection's role is known).
68
+ @store.events.publish(
69
+ :session_opened,
70
+ ctx: Hooks::Context.new(scope: @store.as(@role)),
71
+ role: @role,
72
+ cursor: @session.cursor,
63
73
  )
64
74
 
65
75
  emit_result(rid, {
@@ -79,7 +89,7 @@ module Textus
79
89
  return
80
90
  end
81
91
 
82
- @session.check_etag!(manifest_etag)
92
+ @session.check_etag!(contract_etag)
83
93
 
84
94
  name = params["name"]
85
95
  args = params["arguments"] || {}
@@ -100,8 +110,8 @@ module Textus
100
110
  emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
101
111
  end
102
112
 
103
- def manifest_etag
104
- @store.file_store.etag(File.join(@store.root, "manifest.yaml"))
113
+ def contract_etag
114
+ Textus::Etag.for_contract(@store.root)
105
115
  end
106
116
 
107
117
  def emit_result(rid, result)
@@ -33,7 +33,7 @@ module Textus
33
33
  "stale" => freshness_rows.select { |r| r[:status] == :stale }.map { |r| r[:key] },
34
34
  "pending_review" => review_keys,
35
35
  "doctor" => doctor_summary,
36
- "manifest_etag" => manifest_etag,
36
+ "contract_etag" => contract_etag,
37
37
  "next_due_at" => soonest_due(freshness_rows),
38
38
  "hook_errors" => hook_errors_since(since),
39
39
  }
@@ -76,8 +76,8 @@ module Textus
76
76
  }
77
77
  end
78
78
 
79
- def manifest_etag
80
- @file_store.etag(File.join(@root, "manifest.yaml"))
79
+ def contract_etag
80
+ Textus::Etag.for_contract(@root)
81
81
  end
82
82
 
83
83
  def hook_errors_since(seq)
@@ -1,16 +1,17 @@
1
1
  module Textus
2
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
3
+ # (Ruby) orientation state — the audit cursor plus the contract etag and
4
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
5
+ # returns a new instance. ADR 0036; contract_etag widened in ADR 0074.
6
+ Session = Data.define(:role, :cursor, :propose_zone, :contract_etag) do
7
7
  def advance_cursor(new_cursor) = with(cursor: new_cursor)
8
8
 
9
9
  def check_etag!(observed_etag)
10
- return if observed_etag == manifest_etag
10
+ return if observed_etag == contract_etag
11
11
 
12
12
  raise Textus::MCP::ContractDrift.new(
13
- "manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
13
+ "contract changed (manifest/hooks/schemas were #{short_etag(contract_etag)}, " \
14
+ "now #{short_etag(observed_etag)}); re-run boot",
14
15
  )
15
16
  end
16
17
 
data/lib/textus/store.rb CHANGED
@@ -55,7 +55,7 @@ module Textus
55
55
  role: role,
56
56
  cursor: audit_log.latest_seq,
57
57
  propose_zone: manifest.policy.propose_zone_for(role),
58
- manifest_etag: file_store.etag(File.join(root, "manifest.yaml")),
58
+ contract_etag: Textus::Etag.for_contract(root),
59
59
  )
60
60
  end
61
61
 
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.46.0"
2
+ VERSION = "0.47.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -14,8 +14,9 @@ module Textus
14
14
 
15
15
  verb :build
16
16
  summary "materialize derived entries; publish_to and publish_tree fan out copies"
17
- surfaces :cli
17
+ surfaces :cli, :mcp
18
18
  cli "build"
19
+ around :build_lock
19
20
  arg :prefix, String, required: false, description: "limit the build to keys under this prefix"
20
21
 
21
22
  def initialize(container:, call:)
@@ -25,10 +26,21 @@ module Textus
25
26
  end
26
27
 
27
28
  def call(prefix: nil)
29
+ build_role = @manifest.policy.actor_for("build") or
30
+ raise Textus::UsageError.new(
31
+ "no role holds the 'build' capability",
32
+ hint: "declare a role with `can: [build]` in .textus/manifest.yaml",
33
+ )
34
+ build_call = Textus::Call.build(
35
+ role: build_role,
36
+ correlation_id: @call.correlation_id,
37
+ dry_run: @call.dry_run,
38
+ )
39
+
28
40
  built = []
29
41
  leaves = []
30
42
  pruned = []
31
- context = build_context
43
+ context = build_context(build_call)
32
44
 
33
45
  @manifest.data.entries.each do |mentry|
34
46
  next if prefix && !entry_matches_prefix?(mentry, prefix)
@@ -49,11 +61,11 @@ module Textus
49
61
 
50
62
  private
51
63
 
52
- def build_context
64
+ def build_context(call)
53
65
  Textus::Manifest::Entry::Base::PublishContext.new(
54
66
  container: @container,
55
- call: @call,
56
- reader: reader,
67
+ call: call,
68
+ reader: reader(call),
57
69
  )
58
70
  end
59
71
 
@@ -70,8 +82,8 @@ module Textus
70
82
  end
71
83
  end
72
84
 
73
- def reader
74
- @reader ||= Textus::Read::Get.new(container: @container, call: @call)
85
+ def reader(call)
86
+ Textus::Read::Get.new(container: @container, call: call)
75
87
  end
76
88
  end
77
89
  end
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.46.0
4
+ version: 0.47.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -147,6 +147,7 @@ files:
147
147
  - lib/textus/contract.rb
148
148
  - lib/textus/contract/around.rb
149
149
  - lib/textus/contract/binder.rb
150
+ - lib/textus/contract/resources/build_lock.rb
150
151
  - lib/textus/contract/resources/cursor.rb
151
152
  - lib/textus/contract/sources.rb
152
153
  - lib/textus/contract/view.rb
@@ -222,6 +223,7 @@ files:
222
223
  - lib/textus/hooks/signature.rb
223
224
  - lib/textus/init.rb
224
225
  - lib/textus/init/templates/machine_intake.rb
226
+ - lib/textus/init/templates/orientation_reducer.rb
225
227
  - lib/textus/key/distance.rb
226
228
  - lib/textus/key/grammar.rb
227
229
  - lib/textus/key/path.rb