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 +4 -4
- data/CHANGELOG.md +16 -0
- data/SPEC.md +5 -4
- data/docs/architecture/README.md +2 -2
- data/lib/textus/cli/verb/build.rb +1 -10
- data/lib/textus/cli/verb/init.rb +3 -1
- data/lib/textus/contract/resources/build_lock.rb +17 -0
- data/lib/textus/etag.rb +23 -0
- data/lib/textus/hooks/catalog.rb +1 -0
- data/lib/textus/init/templates/orientation_reducer.rb +17 -0
- data/lib/textus/init.rb +67 -4
- data/lib/textus/mcp/server.rb +14 -4
- data/lib/textus/read/pulse.rb +3 -3
- data/lib/textus/session.rb +6 -5
- data/lib/textus/store.rb +1 -1
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/build.rb +19 -7
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c87fe067b0988d187beac617c4540e1563d0f006a08d6090b2b483c699555d46
|
|
4
|
+
data.tar.gz: 4f0503119ebf3eeff28d7a16aee8dc8c62673e6b41912e85bc3453e596075e65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
"
|
|
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. `
|
|
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
|
|
data/docs/architecture/README.md
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/textus/cli/verb/init.rb
CHANGED
|
@@ -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
|
data/lib/textus/hooks/catalog.rb
CHANGED
|
@@ -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
|
-
|
|
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"),
|
|
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
|
data/lib/textus/mcp/server.rb
CHANGED
|
@@ -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
|
-
|
|
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!(
|
|
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
|
|
104
|
-
|
|
113
|
+
def contract_etag
|
|
114
|
+
Textus::Etag.for_contract(@store.root)
|
|
105
115
|
end
|
|
106
116
|
|
|
107
117
|
def emit_result(rid, result)
|
data/lib/textus/read/pulse.rb
CHANGED
|
@@ -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
|
-
"
|
|
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
|
|
80
|
-
|
|
79
|
+
def contract_etag
|
|
80
|
+
Textus::Etag.for_contract(@root)
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
def hook_errors_since(seq)
|
data/lib/textus/session.rb
CHANGED
|
@@ -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
|
|
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, :
|
|
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 ==
|
|
10
|
+
return if observed_etag == contract_etag
|
|
11
11
|
|
|
12
12
|
raise Textus::MCP::ContractDrift.new(
|
|
13
|
-
"
|
|
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
data/lib/textus/version.rb
CHANGED
data/lib/textus/write/build.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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.
|
|
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
|