diff --git a/CHANGELOG.md b/CHANGELOG.md index fba3ebd..6044b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- The Claude plugin folder now carries the server code at `client-plugin/server/src/plexus/`, so an install of the folder alone starts. `python scripts/build_client_package.py --sync-vendored` rewrites that copy from `src/`, and a test fails when it drifts. The launcher no longer looks outside the plugin folder; a missing copy prints a one-line message and exits with status 1. A test keeps every code and text file under 256 KiB, images under 2 MB, and the folder at 512 files or fewer. +- The skill and the MCP tool descriptions say "tool" where they said "organ". - The Claude plugin manifest adds the plugin directory listing fields: display name, keywords, homepage, repository, documentation, support, privacy and terms links, and a 1024 px icon. Plexus takes no launch settings, so the manifest declares no `userConfig`. Portable and Codex manifests are unchanged. - The client README adds a data and network table derived from the code: Plexus reads built-in and caller-selected manifests, stores nothing, makes no network call and sends no telemetry. Stale binding instructions are removed. - The client README and PRIVACY.md add a "What this plugin runs and handles" section covering hooks, the exact launch command, network, files and environment variables. A test fails if the served package starts importing a network or process module or reading the environment. diff --git a/client-plugin/README.md b/client-plugin/README.md index 61b4de0..092d45a 100644 --- a/client-plugin/README.md +++ b/client-plugin/README.md @@ -15,6 +15,8 @@ Discovery, wiring, plans and routes describe manifests. A declared edge does not ## Install The source ZIP requires Python 3.11 or newer. Extract the entire archive, then point a local stdio MCP client at an absolute Python executable with arguments `-I -S -B server/serve.py` using the absolute script path. Plexus takes no path, account or grant at launch. The source package is an advanced installation, not self-contained. +The plugin folder carries its own copy of the server code in `server/src`, so a directory install needs nothing from the rest of the repository. If that copy is missing, the server prints "plexus: the server code is missing from the plugin folder. Reinstall the plugin." and exits. + In Claude Code, enabling the plugin asks for nothing. Plexus needs no settings, so the Claude manifest declares no `userConfig` and launches `python3 -I -S -B ${CLAUDE_PLUGIN_ROOT}/server/serve.py` with no further arguments. Replace `python3` with a trusted absolute Python path where necessary. The Windows x64 native ZIP includes Python and needs no separate Python or Node installation. Extract everything and use the absolute `server/plexus-local.exe` path with no arguments. A client supporting binary MCPB extensions may open the matching MCPB, which has no settings to enter. Both archives use identical executable bytes. diff --git a/client-plugin/server/serve.py b/client-plugin/server/serve.py index 41b8374..ba38ee2 100644 --- a/client-plugin/server/serve.py +++ b/client-plugin/server/serve.py @@ -8,7 +8,11 @@ TOOL = 'plexus' if not getattr(sys, 'frozen', False): - sys.path.insert(0, str(Path(__file__).resolve().parent / 'src')) + source = Path(__file__).resolve().parent / 'src' + if not (source / TOOL / 'mcp.py').is_file(): + sys.stderr.write(f'{TOOL}: the server code is missing from the plugin folder. Reinstall the plugin.\n') + raise SystemExit(1) + sys.path.insert(0, str(source)) def explicit_path(value, *, directory=False): diff --git a/client-plugin/server/src/plexus/__init__.py b/client-plugin/server/src/plexus/__init__.py new file mode 100644 index 0000000..42d1b6a --- /dev/null +++ b/client-plugin/server/src/plexus/__init__.py @@ -0,0 +1,27 @@ +"""plexus: capability discovery + auto-wiring for agent toolchains. + +Point it at a set of tools that ship interop manifests and declarative discovery +wires producer to consumer into a pipeline, every edge tagged `declared` and +carrying the module its producer names. Discovery does not import or run cited +tools. Optional probe helpers in `plexus.registry` launch owned MCP servers only +when explicitly called. Zero runtime dependencies. +""" +from .graph import to_dot, to_mermaid +from .manifest import Manifest, Port, validate +from .mesh import Edge, Mesh, discover +from .plan import plan_to, route +from .receipt import build_plan_receipt, verify_plan +from .registry import builtin_manifests, load_dir +from .run import pipeline_script + +__version__ = "0.3.0" + +__all__ = [ + "Manifest", "Port", "validate", + "Mesh", "Edge", "discover", + "plan_to", "route", + "build_plan_receipt", "verify_plan", + "to_mermaid", "to_dot", "pipeline_script", + "builtin_manifests", "load_dir", + "__version__", +] diff --git a/client-plugin/server/src/plexus/__main__.py b/client-plugin/server/src/plexus/__main__.py new file mode 100644 index 0000000..053710a --- /dev/null +++ b/client-plugin/server/src/plexus/__main__.py @@ -0,0 +1,4 @@ +from plexus.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/client-plugin/server/src/plexus/cli.py b/client-plugin/server/src/plexus/cli.py new file mode 100644 index 0000000..a2c2f9c --- /dev/null +++ b/client-plugin/server/src/plexus/cli.py @@ -0,0 +1,139 @@ +"""cli.py — plexus command line. + + plexus discover [--builtin] [--dir DIR] # the mesh: organs, edges, orphans + plexus wiring [...] # capability -> (producer, consumer) pairs + plexus plan --goal ORGAN [...] # upstream pipeline that feeds ORGAN + plexus route --from A --to B [...] # capability path from A to B + plexus validate [...] # manifest problems (exit 1 if any) +""" +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone + +from . import __version__ +from .graph import to_dot, to_mermaid +from .manifest import content_hash, duplicate_organs, validate +from .mesh import discover +from .plan import plan_to, route +from .registry import builtin_manifests, export_all, load_dir +from .run import pipeline_script + + +def _load(args) -> list: + mans = list(builtin_manifests()) if (args.builtin or not args.dir) else [] + if args.dir: + mans += load_dir(args.dir) + return mans + + +def _receipt(mesh) -> dict: + """A re-runnable provenance receipt for the mesh: which manifests were read, + from where, and a content hash a stranger can recompute from the same bytes.""" + return { + "plexus_version": __version__, + "generated_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "manifests": [{"organ": m.organ, "source": m.source, + "sha256": content_hash(m)} + for m in sorted(mesh.manifests.values(), key=lambda m: m.organ)], + } + + +def _mesh_json(mesh) -> dict: + return { + "organs": mesh.organs, + "edges": [{"producer": e.producer, "consumer": e.consumer, + "capability": e.capability, "self_loop": e.self_loop, + "via": e.producer_module, "evidence": e.evidence} + for e in mesh.edges], + "orphans": mesh.orphans(), + "collisions": mesh.collisions, + "receipt": _receipt(mesh), + } + + +def _add_source_flags(p): + p.add_argument("--builtin", action="store_true", help="include the built-in flagship manifests") + p.add_argument("--dir", default="", help="load *.interop.json manifests from this directory") + + +def main(argv: "list[str] | None" = None) -> int: + ap = argparse.ArgumentParser(prog="plexus", description=__doc__) + ap.add_argument("--version", action="version", version=f"plexus {__version__}") + sub = ap.add_subparsers(dest="cmd", required=True) + + for name in ("discover", "wiring", "validate"): + sp = sub.add_parser(name) + _add_source_flags(sp) + pp = sub.add_parser("plan"); _add_source_flags(pp) + pp.add_argument("--goal", required=True, help="organ you want to feed") + rp = sub.add_parser("route"); _add_source_flags(rp) + rp.add_argument("--from", dest="src", required=True) + rp.add_argument("--to", dest="dst", required=True) + vp = sub.add_parser("verify"); _add_source_flags(vp) + vp.add_argument("--plan", required=True, + help="a plan/route JSON file (from `plexus plan`/`route`) to re-derive " + "and re-verify against the current mesh") + gp = sub.add_parser("graph"); _add_source_flags(gp) + gp.add_argument("--format", default="mermaid", choices=["mermaid", "dot"]) + up = sub.add_parser("run"); _add_source_flags(up) + up.add_argument("--goal", required=True, help="organ you want to feed") + ep = sub.add_parser("export") + ep.add_argument("--dir", default="manifests", help="write .interop.json files here") + sub.add_parser("mcp") # stdio MCP server: discover/wiring/plan/route as tools + + args = ap.parse_args(argv) + + if args.cmd == "mcp": + from .mcp import serve + return serve() + if args.cmd == "export": + print("\n".join(export_all(args.dir))) + return 0 + + mans = _load(args) + + if args.cmd == "discover": + print(json.dumps(_mesh_json(discover(mans)), indent=2)) + return 0 + if args.cmd == "wiring": + print(json.dumps(discover(mans).wiring(), indent=2)) + return 0 + if args.cmd == "plan": + print(json.dumps(plan_to(discover(mans), args.goal), indent=2)) + return 0 + if args.cmd == "route": + print(json.dumps(route(discover(mans), args.src, args.dst), indent=2)) + return 0 + if args.cmd == "verify": + from .receipt import verify_plan + with open(args.plan, encoding="utf-8") as f: + plan = json.load(f) + ok = verify_plan(plan, discover(mans)) + print(json.dumps({"verified": ok, "target": plan.get("target"), + "source": plan.get("source")}, indent=2)) + return 0 if ok else 1 + if args.cmd == "graph": + mesh = discover(mans) + print(to_mermaid(mesh) if args.format == "mermaid" else to_dot(mesh)) + return 0 + if args.cmd == "run": + print(pipeline_script(discover(mans), args.goal)) + return 0 + if args.cmd == "validate": + problems = {m.organ: validate(m) for m in mans} + report = {k: v for k, v in problems.items() if v} + dups = duplicate_organs(mans) + if dups: + # A set-level clash the per-organ dict above cannot show (its own key + # collapses the duplicates); name it so a collision can never launder + # to {"ok": true}. + report["duplicate_organs"] = dups + print(json.dumps(report or {"ok": True}, indent=2)) + return 1 if report else 0 + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/client-plugin/server/src/plexus/graph.py b/client-plugin/server/src/plexus/graph.py new file mode 100644 index 0000000..6a66383 --- /dev/null +++ b/client-plugin/server/src/plexus/graph.py @@ -0,0 +1,38 @@ +"""graph.py — render the mesh as a diagram (Mermaid / Graphviz DOT). + +Discovery is only useful if you can see it. This turns the mesh into a picture: +organs as nodes, capability-labeled edges as producer -> consumer arrows, with +self-loops and the cross-tool wiring visible at a glance. Mermaid renders in any +Markdown viewer; DOT feeds Graphviz. Pure string output, zero deps. +""" +from __future__ import annotations + +from .mesh import Mesh + + +def _san(node: str) -> str: + """A Mermaid/DOT-safe node id (organs are simple slugs already, but be safe).""" + return "".join(c if c.isalnum() else "_" for c in node) + + +def to_mermaid(mesh: Mesh, *, direction: str = "LR") -> str: + """A Mermaid flowchart. Cross-tool edges are solid; self-loops dotted.""" + lines = [f"flowchart {direction}"] + for organ in mesh.organs: + lines.append(f' {_san(organ)}["{organ}"]') + for e in mesh.edges: + arrow = "-.->" if e.self_loop else "-->" + lines.append(f' {_san(e.producer)} {arrow}|"{e.capability}"| {_san(e.consumer)}') + return "\n".join(lines) + + +def to_dot(mesh: Mesh) -> str: + """A Graphviz DOT digraph.""" + lines = ["digraph plexus {", ' rankdir=LR;', ' node [shape=box];'] + for organ in mesh.organs: + lines.append(f' "{organ}";') + for e in mesh.edges: + attrs = f'label="{e.capability}"' + (", style=dotted" if e.self_loop else "") + lines.append(f' "{e.producer}" -> "{e.consumer}" [{attrs}];') + lines.append("}") + return "\n".join(lines) diff --git a/client-plugin/server/src/plexus/manifest.py b/client-plugin/server/src/plexus/manifest.py new file mode 100644 index 0000000..bd116f9 --- /dev/null +++ b/client-plugin/server/src/plexus/manifest.py @@ -0,0 +1,117 @@ +"""manifest.py — what a tool declares about how it plugs into others. + +A manifest is a tool's interop contract: the capabilities it EMITS (re-checkable +artifacts another tool could consume) and the capabilities it CONSUMES (inputs it +accepts). A capability is a stable string: a schema id like +"mneme.crucible-export/2" or a shared-spine kind like +"project-telos.flagship-action/v1". Matching is by capability string, so an edge +forms when a producer DECLARES a capability the consumer DECLARES it accepts. + +Every port carries `module` (a file:function pointer) the producer names as the +source. plexus does not import, resolve, or run that pointer, so the mesh is +DECLARED, not probed: the module is a self-reported citation to follow, not a +verified receipt. A manifest is a plain dict/JSON, so any tool in any language +can ship one; nothing here imports the tools it describes. +""" +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class Port: + """One capability a tool emits or consumes.""" + capability: str # the match key (schema id or shared kind) + title: str = "" # human label + module: str = "" # evidence: repo-relative file:function + summary: str = "" + consumable_as: tuple = () # extra capabilities an EMIT also satisfies + # (e.g. mneme.crucible-export/2 -> crucible.thesis) + + def satisfies(self, capability: str) -> bool: + return capability == self.capability or capability in self.consumable_as + + +@dataclass +class Manifest: + organ: str + invoke: dict = field(default_factory=dict) # {cli, mcp_server, python_import} + emits: list = field(default_factory=list) # list[Port] + consumes: list = field(default_factory=list) # list[Port] + evidence: list = field(default_factory=list) # file paths the manifest was grounded on + source: str = "" # provenance: "builtin:registry" or the file path read + + @staticmethod + def from_dict(d: dict) -> "Manifest": + def ports(key): + out = [] + for p in d.get(key, []): + if isinstance(p, str): + out.append(Port(capability=p)) + elif isinstance(p, dict): + out.append(Port( + capability=p.get("capability", ""), title=p.get("title", ""), + module=p.get("module", ""), summary=p.get("summary", ""), + consumable_as=tuple(p.get("consumable_as", ())))) + return out + # Tolerant by contract: a missing organ or capability becomes an empty + # string that validate() reports; from_dict never raises on malformed input. + return Manifest(organ=d.get("organ", ""), invoke=d.get("invoke", {}), + emits=ports("emits"), consumes=ports("consumes"), + evidence=list(d.get("evidence", []))) + + def to_dict(self) -> dict: + def dump(ports): + return [{"capability": p.capability, "title": p.title, "module": p.module, + "summary": p.summary, "consumable_as": list(p.consumable_as)} + for p in ports] + return {"organ": self.organ, "invoke": self.invoke, + "emits": dump(self.emits), "consumes": dump(self.consumes), + "evidence": self.evidence} + + +def content_hash(m) -> str: + """sha256 over the manifest's canonical content (its to_dict, sorted keys). + Source is deliberately excluded, so the hash binds WHAT was declared, not + where it was read; a stranger can recompute it from the same bytes.""" + canonical = json.dumps(m.to_dict(), sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def duplicate_organs(manifests: list) -> list: + """Organ ids declared by more than one manifest in the set, sorted unique. + A set-level check: identity is self-declared, so a collision must be NAMED, + never silently resolved last-writer-wins.""" + seen, dups = set(), set() + for m in manifests: + if m.organ in seen: + dups.add(m.organ) + seen.add(m.organ) + return sorted(dups) + + +def validate(m) -> list: + """Problems with a manifest (empty list == valid). A validator reports + malformed input; it never raises on it.""" + issues = [] + if not isinstance(m, Manifest): + return ["not a Manifest"] + if not m.organ or not isinstance(m.organ, str): + issues.append("organ id missing or not a string") + seen = set() + for side in ("emits", "consumes"): + for i, p in enumerate(getattr(m, side)): + if not isinstance(p, Port) or not p.capability: + issues.append(f"{side}[{i}] has no capability") + continue + if side == "emits" and not p.module: + # The producing module is the evidence the "cites its module" + # claim rests on; an emit without it is a receipt-less claim. + issues.append(f"emits[{i}] {p.capability!r} has no module evidence") + key = (side, p.capability) + if key in seen: + issues.append(f"{side} declares {p.capability!r} twice") + seen.add(key) + return issues diff --git a/client-plugin/server/src/plexus/mcp.py b/client-plugin/server/src/plexus/mcp.py new file mode 100644 index 0000000..1205a3b --- /dev/null +++ b/client-plugin/server/src/plexus/mcp.py @@ -0,0 +1,172 @@ +"""mcp.py — a zero-dep MCP server so any agent can query the mesh live. + +A CLI mesh is useful to a human; an agent needs it mid-task. This exposes +discover / wiring / plan / route as MCP tools over stdio JSON-RPC 2.0 (the same +shape every flagship speaks), so a harness can ask "what feeds crucible?" or +"how do gather and crucible connect?" while it works. No SDK, stdlib only. + +The transport (`serve`) is a thin loop; all logic is in `handle`, which maps a +request dict to a response dict and is fully testable without any pipes. +""" +from __future__ import annotations + +import json +import sys + +from . import __version__ +from .mesh import discover +from .plan import plan_to, route +from .registry import builtin_manifests, load_dir + +PROTOCOL = "2025-06-18" + +_DIR = {"dir": {"type": "string", "description": "load extra *.interop.json manifests from here"}} + +TOOLS = [ + {"name": "plexus_discover", + "description": "Discover the interop mesh: tools, producer->consumer edges (each with the module that produces it), and orphans (unmet inputs / unconsumed outputs).", + "inputSchema": {"type": "object", "properties": dict(_DIR)}}, + {"name": "plexus_wiring", + "description": "The wiring map: capability -> list of (producer, consumer) pairs.", + "inputSchema": {"type": "object", "properties": dict(_DIR)}}, + {"name": "plexus_plan", + "description": "The upstream pipeline that feeds a target tool, in dependency order, with any feedback loops reported.", + "inputSchema": {"type": "object", "required": ["goal"], + "properties": {"goal": {"type": "string", "description": "tool to feed"}, **_DIR}}}, + {"name": "plexus_route", + "description": "The shortest capability path from one tool to another.", + "inputSchema": {"type": "object", "required": ["source", "target"], + "properties": {"source": {"type": "string"}, "target": {"type": "string"}, **_DIR}}}, + {"name": "plexus.status", + "description": "Liveness and identity of the plexus MCP server (name, version, protocol). Network-free health probe.", + "inputSchema": {"type": "object", "properties": {}}}, + {"name": "plexus.doctor", + "description": "Readiness diagnostic: identity plus the count of builtin interop manifests and the tools exposed.", + "inputSchema": {"type": "object", "properties": {}}}, +] + +# MCP tool annotations. Every tool reads manifests and returns a result; none +# writes, launches a process or opens a connection. +_TITLES = { + "plexus_discover": "Discover the tool mesh", + "plexus_wiring": "Capability wiring map", + "plexus_plan": "Plan the pipeline for a tool", + "plexus_route": "Route between two tools", + "plexus.status": "Plexus status", + "plexus.doctor": "Plexus readiness check", +} +for _tool in TOOLS: + _tool["title"] = _TITLES[_tool["name"]] + _tool["annotations"] = {"title": _tool["title"], "readOnlyHint": True, + "destructiveHint": False, "idempotentHint": True, + "openWorldHint": False} + + +def _mesh(args: dict): + mans = list(builtin_manifests()) + if args.get("dir"): + mans += load_dir(args["dir"]) + return discover(mans) + + +def _mesh_json(mesh) -> dict: + return {"organs": mesh.organs, + "edges": [{"producer": e.producer, "consumer": e.consumer, + "capability": e.capability, "self_loop": e.self_loop, + "via": e.producer_module} for e in mesh.edges], + "orphans": mesh.orphans()} + + +def _call(params: dict) -> dict: + name, args = params.get("name"), params.get("arguments", {}) or {} + if name in ("plexus.status", "plexus.doctor"): + info = {"ok": True, "server": "plexus", "version": __version__, "protocol": PROTOCOL} + if name == "plexus.doctor": + info["builtin_manifests"] = len(list(builtin_manifests())) + info["tools"] = [t["name"] for t in TOOLS] + return {"content": [{"type": "text", "text": json.dumps(info, indent=2)}]} + try: + mesh = _mesh(args) + if name == "plexus_discover": + result = _mesh_json(mesh) + elif name == "plexus_wiring": + result = mesh.wiring() + elif name == "plexus_plan": + result = plan_to(mesh, args.get("goal", "")) + elif name == "plexus_route": + result = route(mesh, args.get("source", ""), args.get("target", "")) + else: + return {"content": [{"type": "text", "text": f"unknown tool {name!r}"}], "isError": True} + except Exception as e: # a tool error is a result, not a crash + return {"content": [{"type": "text", "text": f"[error] {type(e).__name__}: {e}"}], + "isError": True} + return {"content": [{"type": "text", "text": json.dumps(result, indent=2)}]} + + +def _ok(rid, result): + return {"jsonrpc": "2.0", "id": rid, "result": result} + + +def _err(rid, code, message): + return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}} + + +def _valid_request_id(value) -> bool: + return type(value) is str or type(value) is int + + +def _safe_request_id(req: dict): + if "id" not in req: + return None + rid = req["id"] + return rid if _valid_request_id(rid) else None + + +def handle(req: dict): + """Map one JSON-RPC request to a response dict (or None for notifications).""" + if not isinstance(req, dict): + return _err(None, -32600, "invalid request") + rid = _safe_request_id(req) + if "id" in req and not _valid_request_id(req["id"]): + return _err(None, -32600, "invalid request") + if req.get("jsonrpc") != "2.0": + return _err(rid, -32600, "invalid request") + method = req.get("method") + if not isinstance(method, str): + return _err(rid, -32600, "invalid request: method must be a string") + if "id" not in req: + return None + if method == "initialize": + return _ok(rid, {"protocolVersion": PROTOCOL, "capabilities": {"tools": {}}, + "serverInfo": {"name": "plexus", "version": __version__}}) + if method == "tools/list": + return _ok(rid, {"tools": TOOLS}) + if method == "tools/call": + params = req["params"] if "params" in req else {} + if not isinstance(params, dict): + return _err(rid, -32602, "invalid params") + return _ok(rid, _call(params)) + return _err(rid, -32601, f"method not found: {method}") + + +def serve(stdin=None, stdout=None) -> int: + stdin, stdout = stdin or sys.stdin, stdout or sys.stdout + for line in stdin: + line = line.strip() + if not line: + continue + try: + req = json.loads(line) + except json.JSONDecodeError: + stdout.write(json.dumps(_err(None, -32700, "parse error")) + "\n") + stdout.flush() + continue + resp = handle(req) + if resp is not None: + stdout.write(json.dumps(resp) + "\n") + stdout.flush() + return 0 + + +if __name__ == "__main__": + raise SystemExit(serve()) diff --git a/client-plugin/server/src/plexus/mesh.py b/client-plugin/server/src/plexus/mesh.py new file mode 100644 index 0000000..5def391 --- /dev/null +++ b/client-plugin/server/src/plexus/mesh.py @@ -0,0 +1,88 @@ +"""mesh.py — discover the wiring: whose output plugs into whose input. + +Given a set of manifests, the mesh computes producer -> consumer EDGES: for every +capability a tool consumes, it finds the tools that emit something satisfying it. +This is the layer MCP tool lists lack: MCP tells an agent that tools exist; the +mesh tells it how their OUTPUTS chain into each other's INPUTS. + +An edge is DECLARED, not probed. It forms by matching capability strings the +manifests self-report; plexus never imports, resolves, or runs the tools, so it +cannot confirm the cited module exists or that the outputs truly compose. Every +edge is tagged `evidence="declared"` and carries the module its producer names, +a self-reported citation to follow, not a verified receipt. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from .manifest import duplicate_organs + + +@dataclass(frozen=True) +class Edge: + producer: str # organ id + consumer: str # organ id + capability: str # the consumer's input capability that the producer satisfies + producer_module: str + consumer_module: str + self_loop: bool = False + evidence: str = "declared" # "declared": self-reported, never probed by plexus + + +@dataclass +class Mesh: + organs: list # organ ids, sorted + edges: list # list[Edge] + manifests: dict # organ -> Manifest (deduped, last-writer-wins) + collisions: list = field(default_factory=list) # organ ids declared more than once + + def wiring(self) -> dict: + """capability -> sorted list of (producer, consumer) pairs.""" + out: dict = {} + for e in self.edges: + out.setdefault(e.capability, []).append((e.producer, e.consumer)) + return {k: sorted(v) for k, v in sorted(out.items())} + + def producers_of(self, organ: str) -> list: + """Organs whose output feeds `organ` (its upstream), sorted unique.""" + return sorted({e.producer for e in self.edges + if e.consumer == organ and not e.self_loop}) + + def consumers_of(self, organ: str) -> list: + """Organs that consume `organ`'s output (its downstream), sorted unique.""" + return sorted({e.consumer for e in self.edges + if e.producer == organ and not e.self_loop}) + + def orphans(self) -> dict: + """Honesty surface: capabilities consumed by someone but emitted by no + organ in the set (an unmet input), and emitted but never consumed.""" + emitted, consumed = set(), set() + for m in self.manifests.values(): + for p in m.emits: + emitted.add(p.capability) + emitted.update(p.consumable_as) + for p in m.consumes: + consumed.add(p.capability) + return {"unmet_inputs": sorted(consumed - emitted), + "unconsumed_outputs": sorted(emitted - consumed)} + + +def discover(manifests: list) -> Mesh: + """Build the mesh. An edge (A -> B, cap) exists when B consumes capability + `cap` and A emits a port that satisfies `cap`.""" + mans = {m.organ: m for m in manifests} + edges = [] + for consumer in manifests: + for want in consumer.consumes: + for producer in manifests: + for emit in producer.emits: + if emit.satisfies(want.capability): + edges.append(Edge( + producer=producer.organ, consumer=consumer.organ, + capability=want.capability, + producer_module=emit.module, consumer_module=want.module, + self_loop=(producer.organ == consumer.organ), + evidence="declared")) + edges.sort(key=lambda e: (e.capability, e.producer, e.consumer)) + return Mesh(organs=sorted(mans), edges=edges, manifests=mans, + collisions=duplicate_organs(manifests)) diff --git a/client-plugin/server/src/plexus/plan.py b/client-plugin/server/src/plexus/plan.py new file mode 100644 index 0000000..0b6603f --- /dev/null +++ b/client-plugin/server/src/plexus/plan.py @@ -0,0 +1,120 @@ +"""plan.py — turn the mesh into an executable pipeline. + +Two questions a user actually asks: + - "I want to feed tool X — what upstream chain produces its inputs?" -> plan_to + - "I have tool A's output and want tool B's verdict — how do they connect?" -> route + +Both return ordered steps naming each hop's invocation (the declared CLI) plus +the capability that justifies it. A hop is auditable always; it is runnable only +when both ends declare a CLI. When one does not, the hop is marked +`runnable: false` and the absence is named, never coerced into a command. +""" +from __future__ import annotations + +from .mesh import Mesh +from .receipt import build_plan_receipt + + +def _cli(mesh: Mesh, organ: str): + """The declared CLI for an organ, or None when the manifest declares none. + Absence is returned honestly, never laundered into the organ id.""" + inv = mesh.manifests[organ].invoke if organ in mesh.manifests else {} + return inv.get("cli") + + +def _token(cli, organ: str) -> str: + return cli if cli else f"{organ}(no cli declared)" + + +def _toposort(mesh: Mesh, nodes: set) -> tuple: + """Kahn's algorithm over non-self edges induced on `nodes`. Returns + (order, cyclic): acyclic prefix in dependency order, then any nodes left in a + feedback loop (reported, never silently linearized).""" + edges = [(e.producer, e.consumer) for e in mesh.edges + if not e.self_loop and e.producer in nodes and e.consumer in nodes + and e.producer != e.consumer] + indeg = {n: 0 for n in nodes} + for _, c in edges: + indeg[c] += 1 + ready = sorted(n for n in nodes if indeg[n] == 0) + order = [] + while ready: + n = ready.pop(0) + order.append(n) + for p, c in edges: + if p == n: + indeg[c] -= 1 + if indeg[c] == 0: + ready.append(c) + ready.sort() + cyclic = sorted(n for n in nodes if n not in order) + return order + cyclic, cyclic + + +def plan_to(mesh: Mesh, target: str) -> dict: + """The upstream producers that (transitively) feed `target`, in dependency + order. Pure sources first; any feedback loop is reported in `cyclic` rather + than forced into a false linear order.""" + if target not in mesh.manifests: + return {"target": target, "error": "unknown organ", "order": []} + + upstream, frontier = set(), [target] + while frontier: + node = frontier.pop() + for producer in mesh.producers_of(node): + if producer != target and producer not in upstream: + upstream.add(producer) + frontier.append(producer) + nodes = upstream | {target} + + order, cyclic = _toposort(mesh, nodes) + hops = [] + for e in mesh.edges: + if e.self_loop or e.producer not in nodes or e.consumer not in nodes: + continue + p_cli, c_cli = _cli(mesh, e.producer), _cli(mesh, e.consumer) + hops.append({"producer": e.producer, "consumer": e.consumer, + "capability": e.capability, + "invoke": f"{_token(p_cli, e.producer)} -> {_token(c_cli, e.consumer)}", + "runnable": p_cli is not None and c_cli is not None}) + incoming = {c for _, c in [(e.producer, e.consumer) for e in mesh.edges + if not e.self_loop and e.producer in nodes + and e.consumer in nodes and e.producer != e.consumer]} + plan = {"target": target, "order": order, "hops": hops, "cyclic": cyclic, + "sources": [n for n in order if n not in incoming]} + plan["receipt"] = build_plan_receipt(mesh, plan) + return plan + + +def route(mesh: Mesh, source: str, target: str) -> dict: + """Shortest capability-path from `source` to `target` (BFS over edges).""" + if source not in mesh.manifests or target not in mesh.manifests: + return {"source": source, "target": target, "error": "unknown organ", "path": []} + # BFS keeping the edge used to reach each node + prev: dict = {source: None} + queue = [source] + while queue: + node = queue.pop(0) + if node == target: + break + for e in mesh.edges: + if e.self_loop or e.producer != node or e.consumer in prev: + continue + prev[e.consumer] = e + queue.append(e.consumer) + if target not in prev: + out = {"source": source, "target": target, "path": [], "connected": False} + out["receipt"] = build_plan_receipt(mesh, out) + return out + chain = [] + node = target + while prev[node] is not None: + e = prev[node] + chain.append({"producer": e.producer, "consumer": e.consumer, + "capability": e.capability}) + node = e.producer + chain.reverse() + out = {"source": source, "target": target, "connected": True, + "hops": len(chain), "path": chain} + out["receipt"] = build_plan_receipt(mesh, out) + return out diff --git a/client-plugin/server/src/plexus/receipt.py b/client-plugin/server/src/plexus/receipt.py new file mode 100644 index 0000000..8dca1a5 --- /dev/null +++ b/client-plugin/server/src/plexus/receipt.py @@ -0,0 +1,66 @@ +"""receipt.py -- a re-derivable receipt for a plexus plan or route. + +A plan is a claim: "given THESE manifests, this is the wiring." The receipt binds the +derivation to the exact manifests it came from: the content hash of every organ in the +plan, plus a hash over the derived body. So a stranger holding the mesh and the plan +re-derives it and confirms the receipt, and a manifest that changed since the plan is +caught -- the wiring silently shifting under you is the failure this prevents. + +verify_plan re-derives the plan FROM THE MESH (never trusting the plan's stored body), +so a forger who tampers the body and recomputes the hash is still caught. Zero-dep. +""" +from __future__ import annotations + +import hashlib +import json + +from .manifest import content_hash + +SCHEMA = "plexus.plan-receipt/1" +METHOD_VERSION = "plexus-plan/1" + + +def _plan_organs(plan: dict) -> set: + """Every organ the plan references, so each one's manifest is bound to the receipt.""" + organs: set = set() + organs.update(plan.get("order") or []) + for key in ("source", "target"): + if plan.get(key): + organs.add(plan[key]) + for key in ("hops", "path"): + rows = plan.get(key) + if isinstance(rows, list): # route's `hops` is an int count, skip it; `path` is the list + for hop in rows: + if isinstance(hop, dict): + organs.update(x for x in (hop.get("producer"), hop.get("consumer")) if x) + return organs + + +def _plan_body_sha(plan: dict) -> str: + body = {k: v for k, v in plan.items() if k != "receipt"} + return hashlib.sha256( + json.dumps(body, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest() + + +def build_plan_receipt(mesh, plan: dict) -> dict: + """Bind the plan to the content hash of every organ it references plus its body hash.""" + organs = sorted(o for o in _plan_organs(plan) if o in mesh.manifests) + manifest_shas = {o: content_hash(mesh.manifests[o]) for o in organs} + return {"schema": SCHEMA, "method_version": METHOD_VERSION, + "manifest_shas": manifest_shas, "plan_sha256": _plan_body_sha(plan)} + + +def verify_plan(plan: dict, mesh) -> bool: + """Re-derive the plan from the mesh and confirm its receipt. A tampered body or a + manifest that changed since the plan both fail. Read-only.""" + r = plan.get("receipt") + if not isinstance(r, dict) or r.get("method_version") != METHOD_VERSION: + return False + from .plan import plan_to, route + if "source" in plan and "target" in plan: + fresh = route(mesh, plan["source"], plan["target"]) + elif "target" in plan: + fresh = plan_to(mesh, plan["target"]) + else: + return False + return build_plan_receipt(mesh, fresh) == r diff --git a/client-plugin/server/src/plexus/registry.py b/client-plugin/server/src/plexus/registry.py new file mode 100644 index 0000000..840f15a --- /dev/null +++ b/client-plugin/server/src/plexus/registry.py @@ -0,0 +1,418 @@ +"""registry.py — the built-in manifests, transcribed from a one-time code survey. + +Each capability key and module pointer below was transcribed by hand from the +flagship source during public-source surveys. plexus does not re-read that source +at runtime, so these pointers are DECLARED citations, not probed receipts: if a +flagship renames a cited symbol, the manifest here goes stale silently until the +next manual survey. The Mneme contract was refreshed from public main on +2026-09-14. The Canon and Relay contracts were added from public origin/main on +2026-09-16. Capability keys are aligned across producers and consumers so +a declared edge forms where the code composed at survey time: gather.digest/1 -> +crucible, gather.items/1 -> mneme, mneme.crucible-export/2 (as crucible.thesis/1) +-> crucible, crucible.replay-template/1 -> mneme, +crucible.replay-pack/1 -> crucible, index.verification/1 -> crucible, and +project-telos.flagship-action/v1 envelopes from the organs that declare them -> +index's spine loader. + +External manifests can be loaded from a directory of *.interop.json files with +the same shape (see Manifest.to_dict), so a tool ships its own contract. +""" +from __future__ import annotations + +import json +import os + +from .manifest import Manifest + +FLAGSHIP = "project-telos.flagship-action/v1" + +_SEED = [ + { + "organ": "gather", + "invoke": {"cli": "gather", "mcp_server": "gather.mcp:serve", "python_import": "gather"}, + "emits": [ + {"capability": FLAGSHIP, "title": "flagship-action envelope", + "module": "src/gather/flagship.py:envelope"}, + {"capability": "gather.digest/1", "title": "witnessed digest seal", + "module": "src/gather/digest.py:Digest.to_json"}, + {"capability": "gather.items/1", "title": "per-item provenance receipts", + "module": "src/gather/digest.py:_receipt"}, + {"capability": "gather.catalog-digest/1", "title": "docs/arxiv catalog + digest", + "module": "src/gather/payloads.py:catalog_digest_payload"}, + ], + "consumes": [ + {"capability": "provenance.verdict/1", "title": "external provenance verdict", + "module": "src/gather/provenance.py:SubprocessProvenanceProvider.origin"}, + {"capability": "gather.run-config/1", "title": "multi-source run config", + "module": "src/gather/run_config.py:load_run_config"}, + ], + "evidence": ["src/gather/interop.py", "src/gather/digest.py", + "src/gather/payloads.py", "src/gather/flagship.py"], + }, + { + "organ": "crucible", + "invoke": {"cli": "crucible", "mcp_server": "crucible.mcp:serve", "python_import": "crucible"}, + "emits": [ + {"capability": FLAGSHIP, "title": "flagship-action envelope", + "module": "src/crucible/flagship.py:envelope"}, + {"capability": "crucible.assessment/1", "title": "witnessed assessment", + "module": "src/crucible/assess.py:Assessment.to_dict"}, + {"capability": "project-telos.crucible.measurement-gate/v1", "title": "measurement gate", + "module": "src/crucible/measurement_gate.py:_result"}, + {"capability": "crucible.thesis-export/1", "title": "public thesis contract", + "module": "src/crucible/gate.py:export_thesis"}, + {"capability": "crucible.replay-template/1", "title": "oracle replay template", + "module": "src/crucible/recheck_cmd.py:_write_template"}, + ], + "consumes": [ + {"capability": "crucible.thesis/1", "title": "thesis JSON", + "module": "src/crucible/commands.py:_thesis_from_data"}, + {"capability": "crucible.measurements/1", "title": "per-claim measurements", + "module": "src/crucible/commands.py:_load_measurements"}, + {"capability": "crucible.replay-pack/1", "title": "assessment-bound replay pack", + "module": "src/crucible/recheck_cmd.py:_load_replay_pack"}, + {"capability": "gather.digest/1", "title": "gather digest as evidence", + "module": "src/crucible/ecosystem_measure.py:GatherDigestMeasure"}, + {"capability": "index.verification/1", "title": "index structural verdict", + "module": "src/crucible/ecosystem_measure.py:verify_index_verification"}, + ], + "evidence": ["src/crucible/ecosystem_measure.py", "src/crucible/commands.py", + "src/crucible/assess.py", "src/crucible/gate.py", + "src/crucible/recheck_cmd.py"], + }, + { + "organ": "forum", + "invoke": {"cli": "forum", "mcp_server": "forum.mcp_surface:serve_stdio", "python_import": "forum"}, + "emits": [ + {"capability": FLAGSHIP, "title": "flagship-action envelope", + "module": "src/forum/flagship.py:envelope"}, + {"capability": "forum.flight-recorder/1", "title": "trace -> verifiable ledger", + "module": "src/forum/flight_recorder.py:import_trace"}, + {"capability": "project-telos.action-receipt/v1", "title": "submit action receipt", + "module": "src/forum/receipts.py:submit_receipt"}, + {"capability": "forum.context-capsule/v1", "title": "witnessed context capsule", + "module": "src/forum/context_capsule.py:build_context_capsule"}, + ], + "consumes": [ + {"capability": "external-agent-trace/1", "title": "LangSmith/OTel/AgentOps trace", + "module": "src/forum/flight_recorder.py:normalize_trace"}, + ], + "evidence": ["src/forum/ledger.py", "src/forum/flight_recorder.py", + "src/forum/flagship.py", "src/forum/receipts.py"], + }, + { + "organ": "index", + "invoke": {"cli": "index", "mcp_server": "index_graph.mcp:serve", "python_import": "index_graph"}, + "emits": [ + {"capability": FLAGSHIP, "title": "flagship-action envelope", + "module": "src/index_graph/flagship.py:envelope"}, + {"capability": "project-telos.context-envelope/v1", "title": "budgeted context envelope", + "module": "src/index_graph/context/envelope.py:build_context_envelope"}, + {"capability": "index.verification/1", "title": "structural-claim verdict", + "module": "src/index_graph/verify.py:build_verification"}, + {"capability": "index.invalidation-pin/1", "title": "freshness pin", + "module": "src/index_graph/freshness/invalidate.py:mint_pin"}, + {"capability": "index.wiki/1", "title": "sealed commit-pinned wiki", + "module": "src/index_graph/wiki/pack.py:build_wiki_pack"}, + ], + "consumes": [ + {"capability": FLAGSHIP, "title": "peer envelopes into the operator spine", + "module": "src/index_graph/workbench.py:load_spine"}, + {"capability": "index.invalidation-pin/1", "title": "pin to diff against the tree", + "module": "src/index_graph/freshness/invalidate.py:invalidation_report"}, + {"capability": "index.wiki/1", "title": "sealed wiki to re-verify", + "module": "src/index_graph/wiki/seal.py:verify_wiki"}, + ], + "evidence": ["src/index_graph/flagship.py", "src/index_graph/context/envelope.py", + "src/index_graph/verify.py", "src/index_graph/workbench.py"], + }, + { + "organ": "mneme", + "invoke": {"cli": "mneme", "mcp_server": "mneme.mcp:serve", "python_import": "mneme"}, + "emits": [ + {"capability": "mneme.crucible-export/2", "title": "memory as crucible thesis", + "module": "src/mneme/compose.py:to_crucible_thesis", + "summary": "claims, drift measurements, and declarative mneme.recheck/1 descriptors", + "consumable_as": ["crucible.thesis/1"]}, + {"capability": "crucible.replay-pack/1", "title": "assessment-bound Mneme drift replay", + "module": "src/mneme/replay.py:replay_crucible", + "summary": "replays descriptor-bound measurements without importing Crucible or executing commands"}, + {"capability": "mneme.local-origin-recheck/1", "title": "supported local origin freshness report", + "module": "src/mneme/origin.py:recheck_local_origins", + "summary": "explicit read-only local docs/file-read freshness checks under a caller-approved root"}, + {"capability": "mneme.provenance-chain/1", "title": "memory -> source origin", + "module": "src/mneme/ingest.py:provenance_chain", + "summary": "memory-to-origin receipt chain; external freshness is checked separately when supported"}, + {"capability": "mneme.recall/1", "title": "re-derivable recall receipt", + "module": "src/mneme/receipt.py:RecallReceipt.as_dict"}, + {"capability": "mneme.drift-report/1", "title": "memory faithfulness verdicts", + "module": "src/mneme/drift.py:drift_report"}, + ], + "consumes": [ + {"capability": "gather.items/1", "title": "accountable intake items", + "module": "src/mneme/ingest.py:from_gather"}, + {"capability": "conversation-turns/1", "title": "raw turns to remember", + "module": "src/mneme/memory.py:AgentMemory.remember"}, + {"capability": "crucible.replay-template/1", "title": "assessment-bound measurement replay template", + "module": "src/mneme/replay.py:replay_crucible", + "summary": "decoded Crucible template plus an explicitly supplied Mneme state"}, + ], + "evidence": ["src/mneme/compose.py", "src/mneme/ingest.py", + "src/mneme/origin.py", "src/mneme/receipt.py", + "src/mneme/drift.py", "src/mneme/replay.py", + "tests/test_crucible_replay.py"], + }, + { + "organ": "canon", + "invoke": {"cli": "canon", "mcp_server": "canon.local_mcp:serve", "python_import": "canon"}, + "emits": [ + {"capability": "canon.record/v1", "title": "provider-neutral context record", + "module": "src/canon/schema.py:Record.to_dict", + "summary": "one typed envelope for personality blocks, memories, personas, decisions, and research references"}, + {"capability": "canon.capsule/v1", "title": "continuity capsule", + "module": "src/canon/capsule_build.py:compile_capsule", + "summary": "explicit records.jsonl and atoms.jsonl compile into capsule JSON, Canon Markdown, and readiness artifacts"}, + {"capability": "canon.readiness-probe/1", "title": "capsule readiness probe", + "module": "src/canon/readiness.py:ReadinessProbe.to_dict", + "summary": "target acknowledgement challenge bound to one compiled capsule"}, + {"capability": "canon.bootstrap-witness/1", "title": "context bootstrap witness", + "module": "src/canon/witness.py:BootstrapWitness.to_dict", + "summary": "records source state, capsule identity, readiness outcome, and does-not-prove limits"}, + {"capability": "canon.local-mcp-readonly/1", "title": "read-only Canon MCP surface", + "module": "src/canon/local_mcp.py:TOOLS", + "summary": "status, doctor, blocks, render, validate, and check; this server writes no files"}, + ], + "consumes": [ + {"capability": "canon.record/v1", "title": "validated records from explicit source files", + "module": "src/canon/bootstrap_runtime_inputs.py:_records_from_source", + "summary": "JSONL records are parsed and validated before capsule compile or bootstrap"}, + {"capability": "canon.atom/v1", "title": "validated bootstrap atoms from explicit source files", + "module": "src/canon/bootstrap_runtime_inputs.py:_atoms_from_source", + "summary": "JSONL atoms are parsed and validated before capsule compile or bootstrap"}, + {"capability": "canon.readiness-response/1", "title": "host readiness response", + "module": "src/canon/readiness.py:evaluate_readiness_response", + "summary": "an optional host response is evaluated against the probe; absent response is reported as unknown"}, + {"capability": "canon.capsule/v1", "title": "capsule Markdown carrier verification", + "module": "src/canon/canonmd.py:verify_canon_md", + "summary": "re-renders the embedded carrier capsule and reports drift or mismatch"}, + ], + "evidence": ["README.md", "pyproject.toml", "src/canon/schema.py", + "src/canon/local_mcp.py", "src/canon/capsule_build.py", + "src/canon/bootstrap_runtime_inputs.py", "src/canon/readiness.py", + "src/canon/witness.py", "src/canon/canonmd.py"], + }, + { + "organ": "relay", + "invoke": {"cli": "relay", "mcp_server": "relay.local_mcp:serve", "python_import": "relay"}, + "emits": [ + {"capability": "relay.endpoint-ladder/1", "title": "local and online endpoint ladder", + "module": "src/relay/endpoints.py:build_endpoints", + "summary": "configured provider/API/gateway/cloud rungs are built only from caller-supplied environment"}, + {"capability": "relay.agent-run-result/1", "title": "gated agent run result", + "module": "src/relay/local_loop.py:run_agent", + "summary": "final answer, checkpoint, verification fields, acceptance check, reviewability, and observed route"}, + {"capability": "relay.session-ledger/1", "title": "hash-chained session ledger", + "module": "src/relay/local_session.py:SessionLedger.to_jsonl", + "summary": "append-only run/session trajectory that re-verifies on load"}, + {"capability": "relay.rvc/v1", "title": "Relay-Verified-Correctness certificate", + "module": "src/relay/cert.py:emit_cert", + "summary": "self-contained certificate embedding a witnessed ledger and typed acceptance contract"}, + {"capability": "relay.remote-mcp/1", "title": "remote MCP endpoint", + "module": "src/relay/remote_mcp.py:process", + "summary": "Streamable HTTP MCP endpoint with bearer/OAuth authorization and remote exec forced off unless opted in"}, + ], + "consumes": [ + {"capability": "relay.mcp-run-request/v1", "title": "bounded MCP run request", + "module": "src/relay/local_mcp.py:_request_binding", + "summary": "binds goal, root, backend/model hints, write/exec gates, checks, and compaction budget"}, + {"capability": "relay.rvc/v1", "title": "offline certificate verification", + "module": "src/relay/cert.py:verify_cert", + "summary": "re-derives ALLOW, UNVERIFIABLE, or REFUTED from the embedded ledger and contract"}, + {"capability": "relay.session-ledger/1", "title": "saved session listing and reopening", + "module": "src/relay/session_store.py:get_session", + "summary": "saved ledgers are listed, reloaded, and re-verified from RELAY_SESSION_DIR"}, + ], + "evidence": ["README.md", "pyproject.toml", "src/relay/endpoints.py", + "src/relay/local_mcp.py", "src/relay/local_loop.py", + "src/relay/local_session.py", "src/relay/session_store.py", + "src/relay/cert.py", "src/relay/remote_mcp.py"], + }, + { + "organ": "learn", + "invoke": {"cli": "learn", "mcp_server": "src/mcp.mjs", "node_entry": "src/mcp.mjs"}, + "emits": [ + {"capability": "learn-receipt", "title": "tutor credential/mastery ledger entries", + "module": "src/interop.mjs:receiptEntry"}, + {"capability": "learn.tutor-mastery/1", "title": "mastery gate verdict", + "module": "src/tutor/tutor.mjs:mastery"}, + {"capability": "learn.tutor-prooflesson/1", "title": "proof packet to lesson scaffold", + "module": "src/tutor/prooflesson.mjs:proofLesson"}, + {"capability": "learn.tutor-misconceptions/1", "title": "ranked misconception aggregation", + "module": "src/tutor/misconception.mjs:misconceptions"}, + {"capability": FLAGSHIP, "title": "flagship-action envelope", + "module": "src/interop.mjs:receiptEntry"}, + ], + "consumes": [ + {"capability": "crucible.thesis/1", "title": "crucible thesis for proof lesson", + "module": "src/tutor/prooflesson.mjs:proofLesson"}, + ], + "evidence": ["src/interop.mjs", "src/tutor/tutor.mjs", + "src/tutor/prooflesson.mjs", "src/tutor/misconception.mjs"], + }, + { + "organ": "telos", + "invoke": {"cli": "telos", "mcp_server": "demo/telos-mcp.mjs", "node_entry": "demo/telos-mcp.mjs"}, + "emits": [ + {"capability": "telos.room/1", "title": "five-flagship room summary", + "module": "src/telos-mcp.mjs:telos_room"}, + {"capability": "telos.workflow/1", "title": "golden workflow verification", + "module": "src/telos-mcp.mjs:telos_workflow"}, + {"capability": "telos.status/1", "title": "workbench readiness + next actions", + "module": "src/telos-mcp.mjs:telos_status"}, + {"capability": FLAGSHIP, "title": "flagship-action envelope", + "module": "src/telos-mcp.mjs:telos_status"}, + ], + "consumes": [ + {"capability": "project-telos.flagship-action/v1", "title": "cross-tool action", + "module": "src/telos-mcp.mjs:telos_room"}, + ], + "evidence": ["demo/telos-mcp.mjs"], + }, + { + "organ": "flywheel-infra", + "invoke": {"cli": "flywheel", "python_import": "harness"}, + "emits": [ + {"capability": "flywheel.egress/1", "title": "sealed egress event receipt", + "module": "harness/infra/egress.py:build_egress_receipt"}, + {"capability": "flywheel.lesson/1", "title": "sealed organizational lesson", + "module": "harness/lesson.py:build_lesson"}, + {"capability": "flywheel.tool-call-receipt/1", "title": "sealed tool-call receipt", + "module": "harness/tool_call_receipt.py:build_receipt"}, + {"capability": "flywheel.tadr-classification/1", "title": "sealed tier classification", + "module": "harness/governance/tadr_receipt.py:build_classification_receipt"}, + {"capability": "flywheel.governance-envelope/1", "title": "cross-lane governance state", + "module": "harness/governance_envelope.py:GovernanceEnvelope"}, + {"capability": "flywheel.credential-scan/1", "title": "sealed credential exposure report", + "module": "harness/infra/credential_scanner.py:build_credential_scan_receipt"}, + {"capability": "flywheel.correlated-event/1", "title": "cross-layer correlated detection", + "module": "harness/infra/correlator.py:build_correlated_receipt"}, + {"capability": "flywheel.isolation-test/1", "title": "boundary test verdict", + "module": "harness/infra/isolation_test.py:run_isolation_test"}, + {"capability": "flywheel.kill-switch/1", "title": "dual-confirmed infrastructure stop", + "module": "harness/infra/kill_switch.py:build_kill_receipt"}, + {"capability": "flywheel.run-bom/1", "title": "sealed run bill of materials", + "module": "harness/infra/run_bom.py:RunBOM.sealed"}, + ], + "consumes": [ + {"capability": "accountable-surface.actuation-outcome/1", + "title": "intent-vs-outcome for lesson derivation", + "module": "harness/lesson_mappers.py:intent_outcome_lessons"}, + {"capability": "mneme.drift-report/1", + "title": "memory drift for lesson derivation", + "module": "harness/lesson_mappers.py:drift_lessons"}, + {"capability": "learn.tutor-misconceptions/1", + "title": "graded failures for lesson derivation", + "module": "harness/lesson_mappers.py:misconception_lessons"}, + ], + "evidence": ["harness/lesson.py", "harness/infra/egress.py", + "harness/governance/tadr_receipt.py", + "harness/tool_call_receipt.py"], + }, +] + + +def builtin_manifests() -> list: + """The built-in flagship manifests, tagged with their in-code source. + + Covers: gather, crucible, index, forum, mneme, canon, relay, learn, telos, + flywheel-infra. + """ + out = [] + for d in _SEED: + m = Manifest.from_dict(d) + m.source = "builtin:registry" + out.append(m) + return out + + +def probe_lane(name: str, timeout: float = 10.0) -> dict: + """Probe a lane by spawning its MCP server and calling status/doctor. + + Unlike the declared manifest (which cites source files without running + them), this actually spawns the lane and verifies it responds. Returns + {name, reachable, tools, error}. + + Requires the lane's MCP server to be installed and on PATH. + """ + result = {"name": name, "reachable": False, "tools": [], "error": ""} + try: + from harness.lanes import resolve_mcp_command, LANES + if name not in LANES: + result["error"] = f"unknown lane: {name}" + return result + command = resolve_mcp_command(name) + except ImportError: + result["error"] = "harness.lanes not available (run from flywheel)" + return result + except Exception as e: + result["error"] = f"resolve failed: {e}" + return result + + try: + with MCPClient(command, timeout=timeout, + client_name=f"plexus-probe-{name}") as c: + c.start() + tools_res = c.call_text("tools/list", {}) + if tools_res["ok"]: + import json as _json + try: + data = _json.loads(tools_res["text"]) + if isinstance(data, list): + result["tools"] = data + elif isinstance(data, dict) and "tools" in data: + result["tools"] = data["tools"] + except _json.JSONDecodeError: + pass + status_res = c.call_text("status", {}) + result["reachable"] = status_res["ok"] + except Exception as e: + result["error"] = f"{type(e).__name__}: {e}" + return result + + +def probe_all(timeout: float = 10.0) -> list[dict]: + """Probe all built-in lanes. Returns a list of probe results.""" + try: + from harness.lanes import LANES + names = sorted(LANES.keys()) + except ImportError: + names = [m.organ for m in builtin_manifests()] + return [probe_lane(name, timeout=timeout) for name in names] + + +def load_dir(path: str) -> list: + """Load every *.interop.json manifest in a directory (external tools). Each + manifest records the file it was read from as its provenance source.""" + out = [] + for name in sorted(os.listdir(path)): + if name.endswith(".interop.json"): + full = os.path.join(path, name) + with open(full, encoding="utf-8") as f: + m = Manifest.from_dict(json.load(f)) + m.source = full + out.append(m) + return out + + +def export_all(out_dir: str) -> list: + """Write each built-in manifest as .interop.json — the exact file a + flagship would ship to join the mesh. Returns the paths written.""" + os.makedirs(out_dir, exist_ok=True) + written = [] + for m in builtin_manifests(): + p = os.path.join(out_dir, f"{m.organ}.interop.json") + with open(p, "w", encoding="utf-8") as f: + json.dump(m.to_dict(), f, indent=2, ensure_ascii=False) + f.write("\n") + written.append(p) + return written diff --git a/client-plugin/server/src/plexus/run.py b/client-plugin/server/src/plexus/run.py new file mode 100644 index 0000000..588a9a4 --- /dev/null +++ b/client-plugin/server/src/plexus/run.py @@ -0,0 +1,44 @@ +"""run.py — turn a plan into runnable output. + +`plan_to` says what feeds a target; this emits the concrete, ordered invocation +sequence to actually produce it — a shell script you can read, edit, and run. It +does NOT execute anything itself (the tools live in their own environments); it +makes the plan actionable and auditable instead of leaving it as advice. Feedback +loops are emitted as a labelled comment, never silently linearized into a lie. +""" +from __future__ import annotations + +from .mesh import Mesh +from .plan import _cli, plan_to + + +def pipeline_script(mesh: Mesh, target: str) -> str: + """A runnable, commented shell script that invokes each upstream tool in + dependency order to feed `target`.""" + plan = plan_to(mesh, target) + if plan.get("error"): + return f"# error: {plan['error']} ({target!r})" + + lines = ["#!/usr/bin/env bash", "set -euo pipefail", + f"# plexus pipeline to feed: {target}", + f"# sources: {', '.join(plan['sources']) or '(none)'}"] + if plan["cyclic"]: + lines.append(f"# NOTE: feedback loop {' <-> '.join(plan['cyclic'])} : " + "these tools mutually depend; run order among them is not linear") + lines.append("") + + for organ in plan["order"]: + if organ == target: + continue + inbound = [h for h in plan["hops"] if h["consumer"] == organ] + produced = sorted({h["capability"] for h in plan["hops"] if h["producer"] == organ}) + note = f"produces: {', '.join(produced)}" if produced else "source" + lines.append(f"# {organ}: {note}") + lines.append(f"{_cli(mesh, organ)} run # -> feeds " + f"{', '.join(sorted({h['consumer'] for h in plan['hops'] if h['producer'] == organ}))}") + lines.append("") + + feeders = sorted({h["producer"] for h in plan["hops"] if h["consumer"] == target}) + lines.append(f"# {target}: consumes from {', '.join(feeders) or '(external inputs only)'}") + lines.append(f"{_cli(mesh, target)} run") + return "\n".join(lines) diff --git a/client-plugin/skills/plexus/SKILL.md b/client-plugin/skills/plexus/SKILL.md index f01001b..6d781cd 100644 --- a/client-plugin/skills/plexus/SKILL.md +++ b/client-plugin/skills/plexus/SKILL.md @@ -1,6 +1,6 @@ --- name: plexus-local -description: Use Plexus for its explicit local tool workflow with operator-owned state and permissions. +description: Show which of your local tools produce and consume each capability, and plan the route between two tools, from the tools' published manifests. Use when the user asks how their local tools connect. --- Call plexus.status first. If unavailable, report the connection failure without inventing a result. diff --git a/scripts/build_client_package.py b/scripts/build_client_package.py index 54cef04..0ae18cf 100644 --- a/scripts/build_client_package.py +++ b/scripts/build_client_package.py @@ -68,6 +68,35 @@ def encoded(value): return (json.dumps(value, indent=2, sort_keys=True) + '\n').encode() +VENDORED = 'server/src/' +SYNC_COMMAND = 'python scripts/build_client_package.py --sync-vendored' + + +def vendored(): + """The server code the source ZIP carries under server/src/, keyed by its path there.""" + return {f'{TOOL}/{name}': data for name, data in entries(ROOT / 'src' / TOOL).items()} + + +def client_entries(): + """The authored client-plugin files. The vendored server/src copy is derived from src/, + so it is left out here and added from src/ once, never counted twice.""" + return {name: data for name, data in entries(ROOT / 'client-plugin', CLIENT_EXTENSIONS).items() + if not name.startswith(VENDORED)} + + +def sync_vendored(): + """Rewrite client-plugin/server/src from src/, with LF endings, removing stale files.""" + target = ROOT / 'client-plugin' / VENDORED + if target.exists(): + entries(target) # refuses linked entries before anything is deleted + shutil.rmtree(target) + for name, data in vendored().items(): + path = target / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data.replace(b'\r\n', b'\n')) + return sorted(vendored()) + + def listing(plugin): """Claude manifest: the shared plugin fields plus the directory listing fields. @@ -134,7 +163,7 @@ def build(output, native=False, mode='dev'): output = Path(output).absolute() if output.exists(): raise FileExistsError('output must be a new directory') - files = entries(ROOT / 'client-plugin', CLIENT_EXTENSIONS) + files = client_entries() source = entries(ROOT / 'src' / TOOL) inputs = {f'src/{TOOL}/{name}': data for name, data in source.items()} inputs.update({f'client-plugin/{name}': data for name, data in files.items()}) @@ -188,7 +217,7 @@ def build(output, native=False, mode='dev'): raise ValueError('missing PyInstaller license') files['PYINSTALLER-LICENSE.txt'] = copying[0].read_bytes() else: - files.update({f'server/src/{TOOL}/{name}': data for name, data in source.items()}) + files.update({VENDORED + name: data for name, data in vendored().items()}) if any((ROOT / name).read_bytes() != data for name, data in inputs.items()): raise ValueError('source changed during build') files['QUALIFICATION.json'] = encoded(receipt) @@ -204,9 +233,17 @@ def build(output, native=False, mode='dev'): if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('output') + parser.add_argument('output', nargs='?') + parser.add_argument('--sync-vendored', action='store_true', + help='rewrite client-plugin/server/src from src/ and exit') parser.add_argument('--native', action='store_true') parser.add_argument('--mode', choices=('dev', 'release'), default='dev') args = parser.parse_args() + if args.sync_vendored: + for name in sync_vendored(): + print(VENDORED + name) + raise SystemExit(0) + if not args.output: + parser.error('output is required unless --sync-vendored is given') for item in build(args.output, args.native, args.mode): print(item) diff --git a/src/plexus/mcp.py b/src/plexus/mcp.py index 8e8c8a5..1205a3b 100644 --- a/src/plexus/mcp.py +++ b/src/plexus/mcp.py @@ -24,17 +24,17 @@ TOOLS = [ {"name": "plexus_discover", - "description": "Discover the interop mesh: organs, producer->consumer edges (each with the module that produces it), and orphans (unmet inputs / unconsumed outputs).", + "description": "Discover the interop mesh: tools, producer->consumer edges (each with the module that produces it), and orphans (unmet inputs / unconsumed outputs).", "inputSchema": {"type": "object", "properties": dict(_DIR)}}, {"name": "plexus_wiring", "description": "The wiring map: capability -> list of (producer, consumer) pairs.", "inputSchema": {"type": "object", "properties": dict(_DIR)}}, {"name": "plexus_plan", - "description": "The upstream pipeline that feeds a target organ, in dependency order, with any feedback loops reported.", + "description": "The upstream pipeline that feeds a target tool, in dependency order, with any feedback loops reported.", "inputSchema": {"type": "object", "required": ["goal"], - "properties": {"goal": {"type": "string", "description": "organ to feed"}, **_DIR}}}, + "properties": {"goal": {"type": "string", "description": "tool to feed"}, **_DIR}}}, {"name": "plexus_route", - "description": "The shortest capability path from one organ to another.", + "description": "The shortest capability path from one tool to another.", "inputSchema": {"type": "object", "required": ["source", "target"], "properties": {"source": {"type": "string"}, "target": {"type": "string"}, **_DIR}}}, {"name": "plexus.status", diff --git a/tests/test_client_package.py b/tests/test_client_package.py index 5fad6b6..b95c25e 100644 --- a/tests/test_client_package.py +++ b/tests/test_client_package.py @@ -7,6 +7,8 @@ import sys import zipfile +import shutil + import pytest ROOT = Path(__file__).resolve().parents[1] @@ -231,6 +233,72 @@ def test_traced_environment_reads_are_the_disclosed_python_reads(packaged, tmp_p assert "Plexus's own code reads no environment variable" in text +def lf(data): + return data.replace(b'\r\n', b'\n') + + +def test_vendored_server_code_matches_the_builder(): + import build_client_package as package + target = ROOT / 'client-plugin' / package.VENDORED + committed = {path.relative_to(target).as_posix(): lf(path.read_bytes()) + for path in target.rglob('*') if path.is_file() + and '__pycache__' not in path.parts and path.suffix != '.pyc'} + expected = {name: lf(data) for name, data in package.vendored().items()} + hint = f'client-plugin/server/src is stale; run: {package.SYNC_COMMAND}' + assert sorted(committed) == sorted(expected), hint + assert [n for n in expected if committed[n] != expected[n]] == [], hint + + +def test_builder_counts_the_vendored_copy_once(tmp_path): + import build_client_package as package + assert not any(name.startswith(package.VENDORED) for name in package.client_entries()) + with zipfile.ZipFile(build(tmp_path / 'output')[0]) as z: + names = z.namelist() + receipt = json.loads(z.read('QUALIFICATION.json')) + assert len(names) == len(set(names)) + assert sorted(n for n in names if n.startswith(package.VENDORED)) == \ + sorted(package.VENDORED + n for n in package.vendored()) + assert not any(n.startswith('client-plugin/server/src/') for n in receipt['source_sha256']) + + +def isolated_launch(plugin, tmp_path, requests): + server = json.loads((plugin / '.mcp.json').read_text(encoding='utf-8'))['mcpServers'][TOOL] + def resolve(value): + assert '${user_config.' not in value # Plexus declares no userConfig + return value.replace('${CLAUDE_PLUGIN_ROOT}', str(plugin)) + command = sys.executable if server['command'] == 'python3' else resolve(server['command']) + env = {k: v for k, v in os.environ.items() if k.upper() in {'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'PATH'}} + env.update({k: resolve(v) for k, v in server.get('env', {}).items()}) + return subprocess.run([command, *map(resolve, server['args'])], env=env, cwd=tmp_path, timeout=60, + input=''.join(json.dumps(r) + '\n' for r in requests), capture_output=True, text=True) + + +def test_plugin_folder_alone_launches_with_the_claude_command(tmp_path): + plugin = tmp_path / 'installed' + shutil.copytree(ROOT / 'client-plugin', plugin, ignore=shutil.ignore_patterns('__pycache__')) + p = isolated_launch(plugin, tmp_path, [request(1, 'initialize'), request(2, 'tools/list')]) + assert p.returncode == 0, p.stderr + rows = [json.loads(line) for line in p.stdout.splitlines()] + assert rows[0]['result']['serverInfo']['name'] == TOOL + assert {t['name'] for t in rows[1]['result']['tools']} == { + 'plexus_discover', 'plexus_wiring', 'plexus_plan', 'plexus_route', 'plexus.status', 'plexus.doctor'} + shutil.rmtree(plugin / 'server' / 'src') + p = isolated_launch(plugin, tmp_path, [request(1, 'tools/list')]) + assert p.returncode == 1 and not p.stdout + assert p.stderr.strip() == 'plexus: the server code is missing from the plugin folder. Reinstall the plugin.' + + +def test_plugin_folder_fits_the_directory_limits(): + files = [path for path in (ROOT / 'client-plugin').rglob('*') + if path.is_file() and '__pycache__' not in path.parts] + assert len(files) <= 512 + # The 256 KiB rule covers code and text. Images and fonts are exempt; the icon stays under 2 MB. + exempt = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.woff', '.woff2', '.ttf', '.otf'} + assert [p.name for p in files if p.suffix.lower() not in exempt and p.stat().st_size >= 256 * 1024] == [] + assert [p.name for p in files if p.stat().st_size >= 2 * 1024 * 1024] == [] + assert not [p for p in files if p.name == '.gitattributes'] + + def test_version_drift_refused(tmp_path,monkeypatch): import build_client_package as package (tmp_path/'pyproject.toml').write_text('[project]\nversion="0.9.0"\n')