cordis-bridge 0.1.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cordis Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: cordis-bridge
3
+ Version: 0.1.1
4
+ Summary: CLI and optional MCP bridge for the Cordis cognitive runtime.
5
+ Author: Cordis Contributors
6
+ License: MIT
7
+ Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: cordis-core<0.2,>=0.1.1
16
+ Requires-Dist: cordis-memory<0.2,>=0.1.1
17
+ Requires-Dist: cordis-runtime<0.2,>=0.1.1
18
+ Provides-Extra: mcp
19
+ Requires-Dist: mcp<2,>=1.26; extra == "mcp"
20
+ Dynamic: license-file
21
+
22
+ # Cordis Bridge v0.1
23
+
24
+ `cordis-bridge` exposes the existing CORDIS cognitive loop to a real host.
25
+ It has no model, planner, HTTP server, or execution permission of its own.
26
+
27
+ The bridge provides one shared Service behind two transports:
28
+
29
+ - a JSON-only CLI for local hosts and scripts;
30
+ - an optional stdio MCP server for agent hosts.
31
+
32
+ ```text
33
+ host -> begin -> compact context + control signal
34
+ host -> observe / check-action -> workflow evidence
35
+ host -> finish -> Core feedback + Memory episode
36
+ later host -> begin -> changed cognition
37
+ ```
38
+
39
+ By default, durable state lives in `.cordis/` below the current directory:
40
+
41
+ ```text
42
+ .cordis/state.json Core learning state
43
+ .cordis/cognition.db Memory and relationship graph
44
+ .cordis/focus.json Active host-task focus for CLI/MCP process recovery
45
+ ```
46
+
47
+ ## CLI
48
+
49
+ Every command reads one JSON object from a file or standard input and emits
50
+ one JSON object. The input file defaults to `-` (standard input).
51
+
52
+ ```bat
53
+ cordis begin task.json
54
+ cordis query query.json
55
+ cordis observe event.json
56
+ cordis check-action action.json
57
+ cordis finish feedback.json
58
+ cordis status
59
+ ```
60
+
61
+ Use `--data-dir PATH` before the command to keep a project-specific state
62
+ directory elsewhere.
63
+
64
+ ## MCP
65
+
66
+ Install the optional transport dependency and run:
67
+
68
+ ```bat
69
+ pip install "cordis-bridge[mcp]"
70
+ cordis-mcp --data-dir .cordis
71
+ ```
72
+
73
+ The server exposes `cordis_begin`, `cordis_query`, `cordis_observe`,
74
+ `cordis_check_action`, `cordis_finish`, and `cordis_status`. Each tool takes
75
+ the same JSON contract as the matching CLI command.
76
+
77
+ ## Concurrency boundary
78
+
79
+ CORDIS v0.1 assumes one mutating host per state directory. Run one MCP server
80
+ per `.cordis` directory and keep CLI mutations for that directory sequential.
81
+ The Core JSON state and Bridge focus snapshot are deliberately small local
82
+ stores; multi-writer coordination is not part of this release.
@@ -0,0 +1,61 @@
1
+ # Cordis Bridge v0.1
2
+
3
+ `cordis-bridge` exposes the existing CORDIS cognitive loop to a real host.
4
+ It has no model, planner, HTTP server, or execution permission of its own.
5
+
6
+ The bridge provides one shared Service behind two transports:
7
+
8
+ - a JSON-only CLI for local hosts and scripts;
9
+ - an optional stdio MCP server for agent hosts.
10
+
11
+ ```text
12
+ host -> begin -> compact context + control signal
13
+ host -> observe / check-action -> workflow evidence
14
+ host -> finish -> Core feedback + Memory episode
15
+ later host -> begin -> changed cognition
16
+ ```
17
+
18
+ By default, durable state lives in `.cordis/` below the current directory:
19
+
20
+ ```text
21
+ .cordis/state.json Core learning state
22
+ .cordis/cognition.db Memory and relationship graph
23
+ .cordis/focus.json Active host-task focus for CLI/MCP process recovery
24
+ ```
25
+
26
+ ## CLI
27
+
28
+ Every command reads one JSON object from a file or standard input and emits
29
+ one JSON object. The input file defaults to `-` (standard input).
30
+
31
+ ```bat
32
+ cordis begin task.json
33
+ cordis query query.json
34
+ cordis observe event.json
35
+ cordis check-action action.json
36
+ cordis finish feedback.json
37
+ cordis status
38
+ ```
39
+
40
+ Use `--data-dir PATH` before the command to keep a project-specific state
41
+ directory elsewhere.
42
+
43
+ ## MCP
44
+
45
+ Install the optional transport dependency and run:
46
+
47
+ ```bat
48
+ pip install "cordis-bridge[mcp]"
49
+ cordis-mcp --data-dir .cordis
50
+ ```
51
+
52
+ The server exposes `cordis_begin`, `cordis_query`, `cordis_observe`,
53
+ `cordis_check_action`, `cordis_finish`, and `cordis_status`. Each tool takes
54
+ the same JSON contract as the matching CLI command.
55
+
56
+ ## Concurrency boundary
57
+
58
+ CORDIS v0.1 assumes one mutating host per state directory. Run one MCP server
59
+ per `.cordis` directory and keep CLI mutations for that directory sequential.
60
+ The Core JSON state and Bridge focus snapshot are deliberately small local
61
+ stores; multi-writer coordination is not part of this release.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cordis-bridge"
7
+ version = "0.1.1"
8
+ description = "CLI and optional MCP bridge for the Cordis cognitive runtime."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "Cordis Contributors"}]
13
+ dependencies = [
14
+ "cordis-core>=0.1.1,<0.2",
15
+ "cordis-memory>=0.1.1,<0.2",
16
+ "cordis-runtime>=0.1.1,<0.2",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 2 - Pre-Alpha",
20
+ "Intended Audience :: Developers",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ mcp = ["mcp>=1.26,<2"]
28
+
29
+ [project.scripts]
30
+ cordis = "cordis_bridge.cli:main"
31
+ cordis-mcp = "cordis_bridge.mcp_server:main"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ pythonpath = ["src", "../cordis-core/src", "../cordis-memory/src", "../cordis-runtime/src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """Host transports for the model-agnostic CORDIS cognitive runtime."""
2
+
3
+ from .service import BridgeValidationError, CordisService
4
+
5
+ __version__ = "0.1.1"
6
+
7
+ __all__ = ["BridgeValidationError", "CordisService", "__version__"]
@@ -0,0 +1,105 @@
1
+ """JSON-only local command line transport for CORDIS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Mapping
10
+
11
+ from cordis_core import CordisValidationError
12
+ from cordis_memory import MemoryValidationError
13
+ from cordis_runtime import RuntimeValidationError
14
+
15
+ from .service import BridgeValidationError, CordisService
16
+ from .setup import SetupError, setup_claude_code, setup_codex, setup_hermes, setup_opencode
17
+
18
+
19
+ USER_ERRORS = (BridgeValidationError, CordisValidationError, MemoryValidationError, RuntimeValidationError, SetupError)
20
+
21
+
22
+ def _read_json(source: str) -> Mapping[str, Any]:
23
+ try:
24
+ raw = sys.stdin.read() if source == "-" else Path(source).read_text(encoding="utf-8")
25
+ value = json.loads(raw)
26
+ except (OSError, json.JSONDecodeError) as error:
27
+ raise BridgeValidationError(f"unable to read JSON input: {error}") from error
28
+ if not isinstance(value, dict):
29
+ raise BridgeValidationError("JSON input must be one object")
30
+ return value
31
+
32
+
33
+ def _write_json(value: Mapping[str, Any]) -> None:
34
+ sys.stdout.write(json.dumps(value, ensure_ascii=False, sort_keys=True) + "\n")
35
+
36
+
37
+ def _parser() -> argparse.ArgumentParser:
38
+ parser = argparse.ArgumentParser(prog="cordis", description="JSON bridge for the CORDIS cognitive runtime")
39
+ parser.add_argument("--data-dir", default=".cordis", help="directory containing CORDIS durable state")
40
+ sub = parser.add_subparsers(dest="command", required=True)
41
+ for name in ("begin", "query", "observe", "check-action", "finish"):
42
+ command = sub.add_parser(name)
43
+ command.add_argument("input", nargs="?", default="-", help="JSON file path or - for standard input")
44
+ sub.add_parser("status")
45
+ sub.add_parser("init", help="initialize a local CORDIS state directory")
46
+ setup = sub.add_parser("setup", help="connect CORDIS to a supported host")
47
+ setup.add_argument("host", choices=("codex", "claude-code", "opencode", "hermes", "all"))
48
+ setup.add_argument("--config", help=argparse.SUPPRESS)
49
+ return parser
50
+
51
+
52
+ def main(argv: list[str] | None = None) -> int:
53
+ args = _parser().parse_args(argv)
54
+ try:
55
+ if args.command == "setup":
56
+ setups = {
57
+ "codex": lambda: setup_codex(args.config),
58
+ "claude-code": setup_claude_code,
59
+ "opencode": lambda: setup_opencode(args.config),
60
+ "hermes": setup_hermes,
61
+ }
62
+ selected = tuple(setups) if args.host == "all" else (args.host,)
63
+ results = []
64
+ for host in selected:
65
+ result = setups[host]()
66
+ with CordisService(result["state_dir"]) as service:
67
+ service.initialize()
68
+ result["initialized_data_dir"] = result["state_dir"]
69
+ results.append(result)
70
+ result = {"schema": "cordis.setup.v1", "hosts": results} if args.host == "all" else results[0]
71
+ _write_json(result)
72
+ return 0
73
+ with CordisService(args.data_dir) as service:
74
+ if args.command == "init":
75
+ service.initialize()
76
+ result = {
77
+ "schema": "cordis.bridge.v1",
78
+ "initialized": True,
79
+ "data_dir": str(service.data_dir),
80
+ "next": ["cordis status", "cordis-mcp --data-dir .cordis"],
81
+ }
82
+ elif args.command == "status":
83
+ result = service.status()
84
+ else:
85
+ request = _read_json(args.input)
86
+ commands: dict[str, Callable[[Mapping[str, Any]], dict[str, Any]]] = {
87
+ "begin": service.begin,
88
+ "query": service.query,
89
+ "observe": service.observe,
90
+ "check-action": service.check_action,
91
+ "finish": service.finish,
92
+ }
93
+ result = commands[args.command](request)
94
+ _write_json(result)
95
+ return 0
96
+ except USER_ERRORS as error:
97
+ _write_json({"error": {"type": "validation_error", "message": str(error)}})
98
+ return 2
99
+ except Exception as error: # Keep the machine-readable contract even for host failures.
100
+ _write_json({"error": {"type": "internal_error", "message": str(error)}})
101
+ return 1
102
+
103
+
104
+ if __name__ == "__main__":
105
+ raise SystemExit(main())
@@ -0,0 +1,79 @@
1
+ """Optional FastMCP transport; importing this module does not require MCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ from typing import Any, Mapping
8
+
9
+ from .service import CordisService
10
+
11
+
12
+ class McpUnavailableError(RuntimeError):
13
+ """Raised only when the optional ``mcp`` dependency is absent."""
14
+
15
+
16
+ def create_server(data_dir: str | Path | None = None) -> Any:
17
+ """Build a stdio MCP server around one persistent Bridge service."""
18
+ try:
19
+ from mcp.server.fastmcp import FastMCP
20
+ except ImportError as error:
21
+ raise McpUnavailableError("MCP support requires: pip install 'cordis-bridge[mcp]'") from error
22
+
23
+ service = CordisService(data_dir)
24
+ server = FastMCP(
25
+ "CORDIS",
26
+ instructions=(
27
+ "CORDIS is a model-agnostic cognitive runtime. Call cordis_begin before work, "
28
+ "record meaningful events, then call cordis_finish with observable evidence."
29
+ ),
30
+ )
31
+
32
+ @server.tool(name="cordis_begin", description="Start a structured CORDIS task and receive compact cognitive context.")
33
+ def cordis_begin(payload: dict[str, Any]) -> dict[str, Any]:
34
+ return service.begin(payload)
35
+
36
+ @server.tool(name="cordis_query", description="Retrieve novel project-safe cognition for an active CORDIS task.")
37
+ def cordis_query(payload: dict[str, Any]) -> dict[str, Any]:
38
+ return service.query(payload)
39
+
40
+ @server.tool(name="cordis_observe", description="Record a plan, tool, test, artifact, or error event for an active task.")
41
+ def cordis_observe(payload: dict[str, Any]) -> dict[str, Any]:
42
+ return service.observe(payload)
43
+
44
+ @server.tool(name="cordis_check_action", description="Return a deterministic attention-drift and destructive-action signal.")
45
+ def cordis_check_action(payload: dict[str, Any]) -> dict[str, Any]:
46
+ return service.check_action(payload)
47
+
48
+ @server.tool(name="cordis_finish", description="Finalize a task with outcome, evidence, attribution, and optional lesson.")
49
+ def cordis_finish(payload: dict[str, Any]) -> dict[str, Any]:
50
+ return service.finish(payload)
51
+
52
+ @server.tool(name="cordis_status", description="Return observable Core, Memory, and active-task state.")
53
+ def cordis_status() -> dict[str, Any]:
54
+ return service.status()
55
+
56
+ # Keep ownership explicit for embedding hosts and Bridge tests. FastMCP
57
+ # itself does not manage application resources constructed outside a
58
+ # lifespan callback.
59
+ setattr(server, "cordis_service", service)
60
+ return server
61
+
62
+
63
+ def main(argv: list[str] | None = None) -> int:
64
+ parser = argparse.ArgumentParser(prog="cordis-mcp", description="Run CORDIS as a stdio MCP server")
65
+ parser.add_argument("--data-dir", default=".cordis", help="directory containing CORDIS durable state")
66
+ args = parser.parse_args(argv)
67
+ try:
68
+ server = create_server(args.data_dir)
69
+ except McpUnavailableError as error:
70
+ parser.error(str(error))
71
+ try:
72
+ server.run(transport="stdio")
73
+ finally:
74
+ server.cordis_service.close()
75
+ return 0
76
+
77
+
78
+ if __name__ == "__main__":
79
+ raise SystemExit(main())
@@ -0,0 +1,165 @@
1
+ """Persistent host service shared by the CORDIS CLI and MCP transports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Iterable, Mapping
10
+
11
+ from cordis_core import CordisRuntime
12
+ from cordis_memory import CognitiveStore
13
+ from cordis_runtime import CordisHostRuntime
14
+
15
+
16
+ BRIDGE_SCHEMA = "cordis.bridge.v1"
17
+
18
+
19
+ class BridgeValidationError(ValueError):
20
+ """Raised when a transport breaks the small Bridge request contract."""
21
+
22
+
23
+ def _object(value: Any, field: str = "payload") -> Mapping[str, Any]:
24
+ if not isinstance(value, Mapping):
25
+ raise BridgeValidationError(f"{field} must be an object")
26
+ return value
27
+
28
+
29
+ def _text(value: Any, field: str) -> str:
30
+ if not isinstance(value, str) or not value.strip():
31
+ raise BridgeValidationError(f"{field} must be a non-empty string")
32
+ return value.strip()
33
+
34
+
35
+ def _string_list(value: Any, field: str) -> tuple[str, ...] | None:
36
+ if value is None:
37
+ return None
38
+ if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
39
+ raise BridgeValidationError(f"{field} must be a list of non-empty strings")
40
+ return tuple(item.strip() for item in value)
41
+
42
+
43
+ class CordisService:
44
+ """Persist host focus around the existing Core, Memory, and Runtime APIs.
45
+
46
+ Core owns learning state in ``state.json`` and Memory owns graph state in
47
+ ``cognition.db``. The tiny ``focus.json`` file exists only because CLI
48
+ and MCP calls can arrive in separate processes while Runtime focus is
49
+ intentionally task-local and in-memory.
50
+ """
51
+
52
+ def __init__(self, data_dir: str | Path | None = None):
53
+ self.data_dir = Path(data_dir or ".cordis").resolve()
54
+ self.data_dir.mkdir(parents=True, exist_ok=True)
55
+ self.focus_path = self.data_dir / "focus.json"
56
+ self.core = CordisRuntime(self.data_dir / "state.json")
57
+ self.memory = CognitiveStore(self.data_dir / "cognition.db")
58
+ self.runtime = CordisHostRuntime(self.core, self.memory)
59
+ self._closed = False
60
+ self._restore_focus()
61
+
62
+ def close(self) -> None:
63
+ if not self._closed:
64
+ self.memory.close()
65
+ self._closed = True
66
+
67
+ def __enter__(self) -> "CordisService":
68
+ return self
69
+
70
+ def __exit__(self, *_: Any) -> None:
71
+ self.close()
72
+
73
+ def _restore_focus(self) -> None:
74
+ if not self.focus_path.exists():
75
+ return
76
+ try:
77
+ raw = json.loads(self.focus_path.read_text(encoding="utf-8"))
78
+ except (OSError, json.JSONDecodeError) as error:
79
+ raise BridgeValidationError("focus.json is unreadable; remove it only after resolving active tasks") from error
80
+ if not isinstance(raw, dict) or raw.get("schema") != BRIDGE_SCHEMA or not isinstance(raw.get("tasks"), dict):
81
+ raise BridgeValidationError("focus.json has an unsupported schema")
82
+ tasks = raw["tasks"]
83
+ if any(not isinstance(task_id, str) or not isinstance(focus, dict) for task_id, focus in tasks.items()):
84
+ raise BridgeValidationError("focus.json contains invalid task focus")
85
+ self.runtime.restore_focus(tasks)
86
+
87
+ def _save_focus(self) -> None:
88
+ payload = {
89
+ "schema": BRIDGE_SCHEMA,
90
+ "tasks": self.runtime.export_focus(),
91
+ }
92
+ handle = tempfile.NamedTemporaryFile(
93
+ mode="w", encoding="utf-8", dir=self.data_dir, prefix="focus-", suffix=".tmp", delete=False
94
+ )
95
+ try:
96
+ json.dump(payload, handle, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
97
+ handle.flush()
98
+ os.fsync(handle.fileno())
99
+ handle.close()
100
+ os.replace(handle.name, self.focus_path)
101
+ finally:
102
+ if not handle.closed:
103
+ handle.close()
104
+ if os.path.exists(handle.name):
105
+ os.unlink(handle.name)
106
+
107
+ def initialize(self) -> None:
108
+ """Materialize the three durable stores without inventing experience."""
109
+ self.core.initialize()
110
+ self._save_focus()
111
+
112
+ @staticmethod
113
+ def _result(value: Mapping[str, Any]) -> Dict[str, Any]:
114
+ return {"schema": BRIDGE_SCHEMA, "result": dict(value)}
115
+
116
+ def begin(self, payload: Mapping[str, Any]) -> Dict[str, Any]:
117
+ result = self.runtime.begin(_object(payload))
118
+ self._save_focus()
119
+ return self._result(result)
120
+
121
+ def query(self, payload: Mapping[str, Any]) -> Dict[str, Any]:
122
+ request = _object(payload)
123
+ task_id = _text(request.get("task_id"), "task_id")
124
+ intent = _text(request.get("intent"), "intent")
125
+ scopes = _string_list(request.get("scopes"), "scopes")
126
+ kinds = _string_list(request.get("kinds"), "kinds")
127
+ limit = request.get("limit", 3)
128
+ if not isinstance(limit, int):
129
+ raise BridgeValidationError("limit must be an integer")
130
+ kwargs: Dict[str, Any] = {"intent": intent, "limit": limit}
131
+ if scopes is not None:
132
+ kwargs["scopes"] = scopes
133
+ if kinds is not None:
134
+ kwargs["kinds"] = kinds
135
+ result = self.runtime.query(task_id, **kwargs)
136
+ self._save_focus()
137
+ return self._result(result)
138
+
139
+ def observe(self, payload: Mapping[str, Any]) -> Dict[str, Any]:
140
+ request = _object(payload)
141
+ result = self.runtime.observe(
142
+ _text(request.get("task_id"), "task_id"), _object(request.get("event"), "event")
143
+ )
144
+ return self._result(result)
145
+
146
+ def check_action(self, payload: Mapping[str, Any]) -> Dict[str, Any]:
147
+ request = _object(payload)
148
+ result = self.runtime.check_action(
149
+ _text(request.get("task_id"), "task_id"), _object(request.get("action"), "action")
150
+ )
151
+ return self._result(result)
152
+
153
+ def finish(self, payload: Mapping[str, Any]) -> Dict[str, Any]:
154
+ result = self.runtime.finish(_object(payload))
155
+ self._save_focus()
156
+ return self._result(result)
157
+
158
+ def status(self) -> Dict[str, Any]:
159
+ return {
160
+ "schema": BRIDGE_SCHEMA,
161
+ "data_dir": str(self.data_dir),
162
+ "active_task_count": len(self.runtime.export_focus()),
163
+ "core": self.core.status(),
164
+ "memory": self.memory.status(),
165
+ }
@@ -0,0 +1,274 @@
1
+ """Explicit host setup helpers for CORDIS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any, Dict
12
+
13
+
14
+ BEGIN_MARKER = "# BEGIN CORDIS MANAGED MCP"
15
+ END_MARKER = "# END CORDIS MANAGED MCP"
16
+ PROTOCOL_BEGIN = "<!-- BEGIN CORDIS MANAGED PROTOCOL -->"
17
+ PROTOCOL_END = "<!-- END CORDIS MANAGED PROTOCOL -->"
18
+ PROTOCOL = """<!-- BEGIN CORDIS MANAGED PROTOCOL -->
19
+ ## CORDIS cognitive loop
20
+
21
+ Use the CORDIS MCP tools for substantive tasks that can produce observable work or reusable learning.
22
+
23
+ 1. Call `cordis_begin` before substantive work and follow the returned cognitive context and control signal.
24
+ 2. Call `cordis_observe` only for meaningful plans, tool results, tests, artifacts, or errors; do not log routine chatter.
25
+ 3. Call `cordis_check_action` before destructive or potentially drifting actions.
26
+ 4. Call `cordis_finish` exactly once with observable evidence and an honest outcome before declaring the task complete.
27
+ 5. Skip the loop for greetings, casual conversation, and trivial read-only questions.
28
+ <!-- END CORDIS MANAGED PROTOCOL -->"""
29
+
30
+
31
+ class SetupError(ValueError):
32
+ """Raised when automatic host configuration would be unsafe."""
33
+
34
+
35
+ def _toml_string(value: str) -> str:
36
+ return json.dumps(value, ensure_ascii=False)
37
+
38
+
39
+ def codex_config_path() -> Path:
40
+ root = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
41
+ return root / "config.toml"
42
+
43
+
44
+ def host_state_dir(host: str) -> Path:
45
+ return (Path.home() / ".cordis" / "hosts" / host).resolve()
46
+
47
+
48
+ def _server_command(state_dir: Path) -> list[str]:
49
+ return [sys.executable, "-m", "cordis_bridge.mcp_server", "--data-dir", str(state_dir)]
50
+
51
+
52
+ def _host_executable(name: str) -> str:
53
+ candidates = [name]
54
+ if os.name == "nt":
55
+ candidates = [f"{name}.exe", f"{name}.cmd", f"{name}.bat", name]
56
+ for candidate in candidates:
57
+ found = shutil.which(candidate)
58
+ if found and not found.lower().endswith(".ps1"):
59
+ return found
60
+ raise SetupError(f"{name} is not installed or is not available on PATH")
61
+
62
+
63
+ def _run(command: list[str], *, input_text: str | None = None) -> subprocess.CompletedProcess[str]:
64
+ return subprocess.run(
65
+ command,
66
+ input=input_text,
67
+ text=True,
68
+ encoding="utf-8",
69
+ errors="replace",
70
+ capture_output=True,
71
+ check=False,
72
+ )
73
+
74
+
75
+ def _install_protocol(path: Path) -> tuple[bool, str | None]:
76
+ path = path.expanduser().resolve()
77
+ path.parent.mkdir(parents=True, exist_ok=True)
78
+ original = path.read_text(encoding="utf-8") if path.exists() else ""
79
+ if PROTOCOL_BEGIN in original:
80
+ start = original.index(PROTOCOL_BEGIN)
81
+ end_marker = original.find(PROTOCOL_END, start)
82
+ if end_marker < 0:
83
+ raise SetupError(f"{path} contains an incomplete CORDIS protocol block")
84
+ end = end_marker + len(PROTOCOL_END)
85
+ updated = original[:start] + PROTOCOL + original[end:]
86
+ else:
87
+ separator = "" if not original else ("\n" if original.endswith("\n") else "\n\n")
88
+ updated = original + separator + PROTOCOL + "\n"
89
+ changed = updated != original
90
+ backup = None
91
+ if changed and path.exists():
92
+ backup_path = path.with_suffix(path.suffix + ".cordis-backup")
93
+ shutil.copy2(path, backup_path)
94
+ backup = str(backup_path)
95
+ if changed:
96
+ path.write_text(updated, encoding="utf-8")
97
+ return changed, backup
98
+
99
+
100
+ def _protocol_path(host: str) -> Path:
101
+ if host == "codex":
102
+ root = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
103
+ override = root / "AGENTS.override.md"
104
+ return override if override.exists() else root / "AGENTS.md"
105
+ if host == "claude-code":
106
+ return Path.home() / ".claude" / "CLAUDE.md"
107
+ if host == "opencode":
108
+ return Path.home() / ".config" / "opencode" / "AGENTS.md"
109
+ if host == "hermes":
110
+ return Path.home() / ".hermes" / "AGENTS.md"
111
+ raise SetupError(f"unsupported host: {host}")
112
+
113
+
114
+ def _with_protocol(result: Dict[str, Any]) -> Dict[str, Any]:
115
+ path = _protocol_path(result["host"])
116
+ changed, backup = _install_protocol(path)
117
+ result["protocol_path"] = str(path.resolve())
118
+ result["protocol_changed"] = changed
119
+ result["protocol_backup_path"] = backup
120
+ return result
121
+
122
+
123
+ def _result(host: str, state_dir: Path, **values: Any) -> Dict[str, Any]:
124
+ return {
125
+ "host": host,
126
+ "configured": True,
127
+ "state_dir": str(state_dir),
128
+ "state_policy": "one writer-safe state directory per host; projects remain isolated by project_id",
129
+ **values,
130
+ }
131
+
132
+
133
+ def setup_codex(config_path: str | Path | None = None, state_dir: str | Path | None = None) -> Dict[str, Any]:
134
+ """Install a managed stdio MCP block in the user's Codex config."""
135
+ path = Path(config_path) if config_path is not None else codex_config_path()
136
+ path = path.expanduser().resolve()
137
+ path.parent.mkdir(parents=True, exist_ok=True)
138
+ original = path.read_text(encoding="utf-8") if path.exists() else ""
139
+ state = Path(state_dir).expanduser().resolve() if state_dir else host_state_dir("codex")
140
+
141
+ if "[mcp_servers.cordis]" in original and BEGIN_MARKER not in original:
142
+ raise SetupError(
143
+ f"{path} already contains an unmanaged [mcp_servers.cordis] section; "
144
+ "remove or rename it before running setup"
145
+ )
146
+
147
+ block = "\n".join(
148
+ (
149
+ BEGIN_MARKER,
150
+ "[mcp_servers.cordis]",
151
+ f"command = {_toml_string(sys.executable)}",
152
+ f"args = [\"-m\", \"cordis_bridge.mcp_server\", \"--data-dir\", {_toml_string(str(state))}]",
153
+ "startup_timeout_sec = 30",
154
+ END_MARKER,
155
+ )
156
+ )
157
+
158
+ if BEGIN_MARKER in original:
159
+ start = original.index(BEGIN_MARKER)
160
+ end_marker = original.find(END_MARKER, start)
161
+ if end_marker < 0:
162
+ raise SetupError(f"{path} contains an incomplete CORDIS managed block")
163
+ end = end_marker + len(END_MARKER)
164
+ updated = original[:start] + block + original[end:]
165
+ else:
166
+ separator = "" if not original else ("\n" if original.endswith("\n") else "\n\n")
167
+ updated = original + separator + block + "\n"
168
+
169
+ changed = updated != original
170
+ backup = None
171
+ if changed and path.exists():
172
+ backup = path.with_suffix(path.suffix + ".cordis-backup")
173
+ shutil.copy2(path, backup)
174
+ if changed:
175
+ path.write_text(updated, encoding="utf-8")
176
+
177
+ return _with_protocol(_result(
178
+ "codex",
179
+ state,
180
+ changed=changed,
181
+ config_path=str(path),
182
+ backup_path=str(backup) if backup else None,
183
+ restart_required=True,
184
+ ))
185
+
186
+
187
+ def setup_claude_code(state_dir: str | Path | None = None) -> Dict[str, Any]:
188
+ state = Path(state_dir).expanduser().resolve() if state_dir else host_state_dir("claude-code")
189
+ executable = _host_executable("claude")
190
+ existing = _run([executable, "mcp", "get", "cordis"])
191
+ if existing.returncode == 0:
192
+ if str(state) not in existing.stdout or sys.executable not in existing.stdout:
193
+ raise SetupError("Claude Code already has a different MCP server named cordis")
194
+ return _with_protocol(_result("claude-code", state, changed=False, verified=True, restart_required=True))
195
+ command = [executable, "mcp", "add", "--scope", "user", "cordis", "--", *_server_command(state)]
196
+ added = _run(command)
197
+ if added.returncode != 0:
198
+ raise SetupError(f"Claude Code rejected CORDIS MCP setup: {(added.stderr or added.stdout).strip()}")
199
+ verified = _run([executable, "mcp", "get", "cordis"])
200
+ if verified.returncode != 0:
201
+ raise SetupError("Claude Code saved CORDIS but could not verify the server")
202
+ return _with_protocol(_result("claude-code", state, changed=True, verified=True, restart_required=True))
203
+
204
+
205
+ def setup_hermes(state_dir: str | Path | None = None) -> Dict[str, Any]:
206
+ state = Path(state_dir).expanduser().resolve() if state_dir else host_state_dir("hermes")
207
+ executable = _host_executable("hermes")
208
+ existing = _run([executable, "mcp", "test", "cordis"])
209
+ if existing.returncode == 0 and "Connected" in existing.stdout:
210
+ return _with_protocol(_result("hermes", state, changed=False, verified=True, restart_required=True))
211
+ command = [
212
+ executable,
213
+ "mcp",
214
+ "add",
215
+ "cordis",
216
+ "--command",
217
+ sys.executable,
218
+ "--args",
219
+ "-m",
220
+ "cordis_bridge.mcp_server",
221
+ "--data-dir",
222
+ str(state),
223
+ ]
224
+ added = _run(command, input_text="y\n")
225
+ if added.returncode != 0 or "Saved 'cordis'" not in added.stdout:
226
+ raise SetupError(f"Hermes rejected CORDIS MCP setup: {(added.stderr or added.stdout).strip()}")
227
+ verified = _run([executable, "mcp", "test", "cordis"])
228
+ if verified.returncode != 0:
229
+ raise SetupError(f"Hermes saved CORDIS but connection verification failed: {verified.stdout.strip()}")
230
+ return _with_protocol(_result("hermes", state, changed=True, verified=True, restart_required=True))
231
+
232
+
233
+ def opencode_config_path() -> Path:
234
+ return (Path.home() / ".config" / "opencode" / "opencode.json").resolve()
235
+
236
+
237
+ def setup_opencode(
238
+ config_path: str | Path | None = None, state_dir: str | Path | None = None
239
+ ) -> Dict[str, Any]:
240
+ path = Path(config_path).expanduser().resolve() if config_path else opencode_config_path()
241
+ state = Path(state_dir).expanduser().resolve() if state_dir else host_state_dir("opencode")
242
+ path.parent.mkdir(parents=True, exist_ok=True)
243
+ original_text = path.read_text(encoding="utf-8") if path.exists() else ""
244
+ try:
245
+ config = json.loads(original_text) if original_text else {"$schema": "https://opencode.ai/config.json"}
246
+ except json.JSONDecodeError as error:
247
+ raise SetupError(f"{path} is not strict JSON; CORDIS will not rewrite JSONC automatically") from error
248
+ if not isinstance(config, dict):
249
+ raise SetupError(f"{path} must contain one JSON object")
250
+ mcp = config.setdefault("mcp", {})
251
+ if not isinstance(mcp, dict):
252
+ raise SetupError(f"{path} field 'mcp' must be an object")
253
+ desired = {"type": "local", "command": _server_command(state), "enabled": True, "timeout": 30000}
254
+ existing = mcp.get("cordis")
255
+ if existing is not None and existing != desired:
256
+ raise SetupError(f"{path} already contains a different mcp.cordis configuration")
257
+ mcp["cordis"] = desired
258
+ updated = json.dumps(config, ensure_ascii=False, indent=2) + "\n"
259
+ changed = updated != original_text
260
+ backup = None
261
+ if changed and path.exists():
262
+ backup = path.with_suffix(path.suffix + ".cordis-backup")
263
+ shutil.copy2(path, backup)
264
+ if changed:
265
+ path.write_text(updated, encoding="utf-8")
266
+ return _with_protocol(_result(
267
+ "opencode",
268
+ state,
269
+ changed=changed,
270
+ verified=True,
271
+ config_path=str(path),
272
+ backup_path=str(backup) if backup else None,
273
+ restart_required=True,
274
+ ))
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: cordis-bridge
3
+ Version: 0.1.1
4
+ Summary: CLI and optional MCP bridge for the Cordis cognitive runtime.
5
+ Author: Cordis Contributors
6
+ License: MIT
7
+ Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: cordis-core<0.2,>=0.1.1
16
+ Requires-Dist: cordis-memory<0.2,>=0.1.1
17
+ Requires-Dist: cordis-runtime<0.2,>=0.1.1
18
+ Provides-Extra: mcp
19
+ Requires-Dist: mcp<2,>=1.26; extra == "mcp"
20
+ Dynamic: license-file
21
+
22
+ # Cordis Bridge v0.1
23
+
24
+ `cordis-bridge` exposes the existing CORDIS cognitive loop to a real host.
25
+ It has no model, planner, HTTP server, or execution permission of its own.
26
+
27
+ The bridge provides one shared Service behind two transports:
28
+
29
+ - a JSON-only CLI for local hosts and scripts;
30
+ - an optional stdio MCP server for agent hosts.
31
+
32
+ ```text
33
+ host -> begin -> compact context + control signal
34
+ host -> observe / check-action -> workflow evidence
35
+ host -> finish -> Core feedback + Memory episode
36
+ later host -> begin -> changed cognition
37
+ ```
38
+
39
+ By default, durable state lives in `.cordis/` below the current directory:
40
+
41
+ ```text
42
+ .cordis/state.json Core learning state
43
+ .cordis/cognition.db Memory and relationship graph
44
+ .cordis/focus.json Active host-task focus for CLI/MCP process recovery
45
+ ```
46
+
47
+ ## CLI
48
+
49
+ Every command reads one JSON object from a file or standard input and emits
50
+ one JSON object. The input file defaults to `-` (standard input).
51
+
52
+ ```bat
53
+ cordis begin task.json
54
+ cordis query query.json
55
+ cordis observe event.json
56
+ cordis check-action action.json
57
+ cordis finish feedback.json
58
+ cordis status
59
+ ```
60
+
61
+ Use `--data-dir PATH` before the command to keep a project-specific state
62
+ directory elsewhere.
63
+
64
+ ## MCP
65
+
66
+ Install the optional transport dependency and run:
67
+
68
+ ```bat
69
+ pip install "cordis-bridge[mcp]"
70
+ cordis-mcp --data-dir .cordis
71
+ ```
72
+
73
+ The server exposes `cordis_begin`, `cordis_query`, `cordis_observe`,
74
+ `cordis_check_action`, `cordis_finish`, and `cordis_status`. Each tool takes
75
+ the same JSON contract as the matching CLI command.
76
+
77
+ ## Concurrency boundary
78
+
79
+ CORDIS v0.1 assumes one mutating host per state directory. Run one MCP server
80
+ per `.cordis` directory and keep CLI mutations for that directory sequential.
81
+ The Core JSON state and Bridge focus snapshot are deliberately small local
82
+ stores; multi-writer coordination is not part of this release.
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/cordis_bridge/__init__.py
5
+ src/cordis_bridge/cli.py
6
+ src/cordis_bridge/mcp_server.py
7
+ src/cordis_bridge/service.py
8
+ src/cordis_bridge/setup.py
9
+ src/cordis_bridge.egg-info/PKG-INFO
10
+ src/cordis_bridge.egg-info/SOURCES.txt
11
+ src/cordis_bridge.egg-info/dependency_links.txt
12
+ src/cordis_bridge.egg-info/entry_points.txt
13
+ src/cordis_bridge.egg-info/requires.txt
14
+ src/cordis_bridge.egg-info/top_level.txt
15
+ tests/test_cli.py
16
+ tests/test_mcp.py
17
+ tests/test_service.py
18
+ tests/test_setup.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ cordis = cordis_bridge.cli:main
3
+ cordis-mcp = cordis_bridge.mcp_server:main
@@ -0,0 +1,6 @@
1
+ cordis-core<0.2,>=0.1.1
2
+ cordis-memory<0.2,>=0.1.1
3
+ cordis-runtime<0.2,>=0.1.1
4
+
5
+ [mcp]
6
+ mcp<2,>=1.26
@@ -0,0 +1 @@
1
+ cordis_bridge
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+
12
+ ROOT = Path(__file__).resolve().parents[2]
13
+
14
+
15
+ class CordisCliTests(unittest.TestCase):
16
+ def setUp(self) -> None:
17
+ self.tempdir = tempfile.TemporaryDirectory()
18
+ self.data_dir = Path(self.tempdir.name) / ".cordis"
19
+ self.env = dict(os.environ)
20
+ source_paths = [
21
+ str(ROOT / "cordis-bridge" / "src"),
22
+ str(ROOT / "cordis-runtime" / "src"),
23
+ str(ROOT / "cordis-memory" / "src"),
24
+ str(ROOT / "cordis-core" / "src"),
25
+ ]
26
+ self.env["PYTHONPATH"] = os.pathsep.join(source_paths + [self.env.get("PYTHONPATH", "")])
27
+
28
+ def tearDown(self) -> None:
29
+ self.tempdir.cleanup()
30
+
31
+ def call(self, command: str, payload: dict | None = None) -> tuple[int, dict]:
32
+ args = [sys.executable, "-m", "cordis_bridge.cli", "--data-dir", str(self.data_dir), command]
33
+ completed = subprocess.run(
34
+ args,
35
+ input=json.dumps(payload) if payload is not None else None,
36
+ text=True,
37
+ capture_output=True,
38
+ env=self.env,
39
+ check=False,
40
+ )
41
+ return completed.returncode, json.loads(completed.stdout)
42
+
43
+ @staticmethod
44
+ def task() -> dict:
45
+ return {
46
+ "task": {
47
+ "goal": "Start an HTTP adapter in a clean environment",
48
+ "domain": "software",
49
+ "project_id": "cli-test",
50
+ "strategy_id": "import_runtime_dependencies",
51
+ "stakes": "medium",
52
+ },
53
+ "complexity": 0.4,
54
+ "acceptance_evidence": ["clean-environment test passes"],
55
+ }
56
+
57
+ def test_cli_closes_the_learning_loop_across_processes(self) -> None:
58
+ code, first = self.call("begin", self.task())
59
+ self.assertEqual(code, 0)
60
+ task_id = first["result"]["task_id"]
61
+
62
+ code, finished = self.call(
63
+ "finish",
64
+ {
65
+ "task_id": task_id,
66
+ "outcome": "failure",
67
+ "attribution": "strategy",
68
+ "lesson": "Avoid import-time runtime initialization in reusable modules.",
69
+ "evidence": [{"kind": "strategy", "passed": False, "summary": "clean import failed"}],
70
+ },
71
+ )
72
+ self.assertEqual(code, 0)
73
+ self.assertIn("strategy_outcome", finished["result"]["learning"]["state_updates"])
74
+
75
+ code, second = self.call("begin", self.task())
76
+ self.assertEqual(code, 0)
77
+ self.assertEqual(second["result"]["cognitive_ir"]["strategy"]["status"], "avoid_until_revalidated")
78
+
79
+ def test_cli_init_materializes_three_local_state_stores(self) -> None:
80
+ code, result = self.call("init")
81
+ self.assertEqual(code, 0)
82
+ self.assertTrue(result["initialized"])
83
+ self.assertEqual(result["data_dir"], str(self.data_dir.resolve()))
84
+ self.assertTrue((self.data_dir / "state.json").is_file())
85
+ self.assertTrue((self.data_dir / "cognition.db").is_file())
86
+ self.assertTrue((self.data_dir / "focus.json").is_file())
87
+
88
+ def test_cli_uses_json_error_contract(self) -> None:
89
+ code, result = self.call("finish", {"task_id": "missing", "outcome": "failure", "evidence": []})
90
+ self.assertEqual(code, 2)
91
+ self.assertEqual(result["error"]["type"], "validation_error")
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import asyncio
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ if importlib.util.find_spec("mcp"):
11
+ from mcp import ClientSession
12
+ from mcp.client.stdio import StdioServerParameters, stdio_client
13
+ else: # Keep the source suite usable without the optional transport dependency.
14
+ ClientSession = StdioServerParameters = stdio_client = None
15
+
16
+ from cordis_bridge.mcp_server import create_server
17
+
18
+
19
+ @unittest.skipUnless(importlib.util.find_spec("mcp"), "optional mcp package is not installed")
20
+ class CordisMcpTests(unittest.TestCase):
21
+ def test_server_registers_the_six_bridge_tools(self) -> None:
22
+ with tempfile.TemporaryDirectory() as directory:
23
+ server = create_server(directory)
24
+ try:
25
+ names = {tool.name for tool in asyncio.run(server.list_tools())}
26
+ finally:
27
+ server.cordis_service.close()
28
+ self.assertEqual(
29
+ names,
30
+ {
31
+ "cordis_begin",
32
+ "cordis_query",
33
+ "cordis_observe",
34
+ "cordis_check_action",
35
+ "cordis_finish",
36
+ "cordis_status",
37
+ },
38
+ )
39
+
40
+
41
+ @unittest.skipUnless(importlib.util.find_spec("mcp"), "optional mcp package is not installed")
42
+ class CordisMcpStdioTests(unittest.IsolatedAsyncioTestCase):
43
+ async def test_stdio_server_handles_a_real_begin_request(self) -> None:
44
+ with tempfile.TemporaryDirectory() as directory:
45
+ parameters = StdioServerParameters(
46
+ command=sys.executable,
47
+ args=["-m", "cordis_bridge.mcp_server", "--data-dir", str(Path(directory) / "state")],
48
+ )
49
+ async with stdio_client(parameters) as (read, write):
50
+ async with ClientSession(read, write) as session:
51
+ await session.initialize()
52
+ listed = await session.list_tools()
53
+ self.assertIn("cordis_begin", {tool.name for tool in listed.tools})
54
+ result = await session.call_tool(
55
+ "cordis_begin",
56
+ {
57
+ "payload": {
58
+ "task": {
59
+ "goal": "Verify the MCP transport",
60
+ "domain": "software",
61
+ "project_id": "mcp-test",
62
+ "strategy_id": "inspect",
63
+ "stakes": "low",
64
+ },
65
+ "complexity": 0.1,
66
+ "acceptance_evidence": ["MCP response is returned"],
67
+ }
68
+ },
69
+ )
70
+ self.assertFalse(result.isError)
71
+ self.assertIn("cordis.runtime.v1", result.content[0].text)
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ from cordis_bridge import CordisService
8
+
9
+
10
+ class CordisServiceTests(unittest.TestCase):
11
+ def setUp(self) -> None:
12
+ self.tempdir = tempfile.TemporaryDirectory()
13
+ self.data_dir = Path(self.tempdir.name) / ".cordis"
14
+
15
+ def tearDown(self) -> None:
16
+ self.tempdir.cleanup()
17
+
18
+ @staticmethod
19
+ def task() -> dict:
20
+ return {
21
+ "task": {
22
+ "goal": "Inspect the login integration test logs",
23
+ "domain": "software",
24
+ "project_id": "bridge-test",
25
+ "strategy_id": "inspect_logs",
26
+ "stakes": "medium",
27
+ },
28
+ "complexity": 0.4,
29
+ "current_step": "Inspect the failing integration test",
30
+ "constraints": ["Do not change the database schema"],
31
+ "acceptance_evidence": ["login integration test passes"],
32
+ }
33
+
34
+ def test_service_persists_active_focus_for_a_new_process(self) -> None:
35
+ with CordisService(self.data_dir) as first:
36
+ begin = first.begin(self.task())
37
+ task_id = begin["result"]["task_id"]
38
+
39
+ with CordisService(self.data_dir) as second:
40
+ action = second.check_action(
41
+ {
42
+ "task_id": task_id,
43
+ "action": {"description": "Inspect login logs", "purpose": "Find test failure"},
44
+ }
45
+ )
46
+ self.assertTrue(action["result"]["aligned"])
47
+ second.observe(
48
+ {
49
+ "task_id": task_id,
50
+ "event": {
51
+ "event_type": "tool_failed",
52
+ "tool": "shell",
53
+ "subject": "integration test",
54
+ "expected": "tests pass",
55
+ "actual": "exit code 1",
56
+ "error_class": "test_failure",
57
+ },
58
+ }
59
+ )
60
+ second.finish(
61
+ {
62
+ "task_id": task_id,
63
+ "outcome": "failure",
64
+ "attribution": "strategy",
65
+ "lesson": "Inspect configuration before relying only on logs.",
66
+ "evidence": [{"kind": "strategy", "passed": False, "summary": "log-only approach failed"}],
67
+ }
68
+ )
69
+
70
+ with CordisService(self.data_dir) as third:
71
+ changed = third.begin(self.task())
72
+ context = changed["result"]
73
+ self.assertEqual(context["cognitive_ir"]["strategy"]["status"], "avoid_until_revalidated")
74
+ self.assertEqual(context["control"]["mode"], "high_intervention")
75
+ self.assertIn("repeat_failed_strategy:inspect_logs", context["model_context"])
76
+ self.assertEqual(third.status()["active_task_count"], 1)
77
+
78
+ def test_status_combines_core_memory_and_active_tasks(self) -> None:
79
+ with CordisService(self.data_dir) as service:
80
+ service.begin(self.task())
81
+ status = service.status()
82
+ self.assertEqual(status["schema"], "cordis.bridge.v1")
83
+ self.assertEqual(status["active_task_count"], 1)
84
+ self.assertEqual(status["core"]["task_count"], 1)
85
+ self.assertIn("memory_counts", status["memory"])
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest.mock import patch
7
+
8
+ from cordis_bridge.setup import (
9
+ BEGIN_MARKER,
10
+ PROTOCOL_BEGIN,
11
+ SetupError,
12
+ setup_claude_code,
13
+ setup_codex,
14
+ setup_hermes,
15
+ setup_opencode,
16
+ )
17
+
18
+
19
+ class CordisCodexSetupTests(unittest.TestCase):
20
+ def setUp(self) -> None:
21
+ self.tempdir = tempfile.TemporaryDirectory()
22
+ self.config = Path(self.tempdir.name) / "config.toml"
23
+
24
+ def tearDown(self) -> None:
25
+ self.tempdir.cleanup()
26
+
27
+ def test_setup_adds_managed_block_and_preserves_existing_config(self) -> None:
28
+ self.config.write_text('model = "example"\n', encoding="utf-8")
29
+ with patch("cordis_bridge.setup.sys.executable", r"C:\Cordis\python.exe"):
30
+ result = setup_codex(self.config, Path(self.tempdir.name) / "state")
31
+ content = self.config.read_text(encoding="utf-8")
32
+ self.assertIn('model = "example"', content)
33
+ self.assertIn(BEGIN_MARKER, content)
34
+ self.assertIn('[mcp_servers.cordis]', content)
35
+ self.assertIn('command = "C:\\\\Cordis\\\\python.exe"', content)
36
+ self.assertTrue(result["changed"])
37
+ self.assertTrue(Path(result["backup_path"]).is_file())
38
+
39
+ def test_setup_is_idempotent(self) -> None:
40
+ first = setup_codex(self.config)
41
+ second = setup_codex(self.config)
42
+ self.assertTrue(first["changed"])
43
+ self.assertFalse(second["changed"])
44
+ self.assertEqual(self.config.read_text(encoding="utf-8").count(BEGIN_MARKER), 1)
45
+
46
+ def test_setup_refuses_unmanaged_existing_server(self) -> None:
47
+ self.config.write_text("[mcp_servers.cordis]\ncommand = 'custom'\n", encoding="utf-8")
48
+ with self.assertRaises(SetupError):
49
+ setup_codex(self.config)
50
+
51
+ @patch("cordis_bridge.setup._protocol_path")
52
+ def test_setup_installs_managed_usage_protocol(self, protocol_path) -> None:
53
+ protocol = Path(self.tempdir.name) / "AGENTS.md"
54
+ protocol_path.return_value = protocol
55
+ setup_codex(self.config, Path(self.tempdir.name) / "state")
56
+ first = protocol.read_text(encoding="utf-8")
57
+ self.assertIn(PROTOCOL_BEGIN, first)
58
+ self.assertIn("cordis_begin", first)
59
+ setup_codex(self.config, Path(self.tempdir.name) / "state")
60
+ self.assertEqual(protocol.read_text(encoding="utf-8").count(PROTOCOL_BEGIN), 1)
61
+
62
+ def test_opencode_setup_merges_official_local_mcp_schema(self) -> None:
63
+ self.config.write_text('{"model": "example"}\n', encoding="utf-8")
64
+ state = Path(self.tempdir.name) / "opencode-state"
65
+ with patch("cordis_bridge.setup._protocol_path", return_value=Path(self.tempdir.name) / "AGENTS.md"):
66
+ result = setup_opencode(self.config, state)
67
+ value = __import__("json").loads(self.config.read_text(encoding="utf-8"))
68
+ self.assertEqual(value["model"], "example")
69
+ self.assertEqual(value["mcp"]["cordis"]["type"], "local")
70
+ self.assertEqual(value["mcp"]["cordis"]["command"][-1], str(state.resolve()))
71
+ self.assertTrue(value["mcp"]["cordis"]["enabled"])
72
+ self.assertTrue(result["changed"])
73
+
74
+ @patch("cordis_bridge.setup._host_executable", return_value="claude")
75
+ @patch("cordis_bridge.setup._run")
76
+ def test_claude_setup_uses_official_user_scope_cli(self, run, _host) -> None:
77
+ from subprocess import CompletedProcess
78
+
79
+ run.side_effect = [
80
+ CompletedProcess([], 1, "", "not found"),
81
+ CompletedProcess([], 0, "added", ""),
82
+ CompletedProcess([], 0, "connected", ""),
83
+ ]
84
+ with patch("cordis_bridge.setup._protocol_path", return_value=Path(self.tempdir.name) / "CLAUDE.md"):
85
+ result = setup_claude_code(Path(self.tempdir.name) / "claude-state")
86
+ add = run.call_args_list[1].args[0]
87
+ self.assertEqual(add[:6], ["claude", "mcp", "add", "--scope", "user", "cordis"])
88
+ self.assertTrue(result["verified"])
89
+
90
+ @patch("cordis_bridge.setup._host_executable", return_value="hermes")
91
+ @patch("cordis_bridge.setup._run")
92
+ def test_hermes_setup_confirms_tools_and_verifies_connection(self, run, _host) -> None:
93
+ from subprocess import CompletedProcess
94
+
95
+ run.side_effect = [
96
+ CompletedProcess([], 1, "missing", ""),
97
+ CompletedProcess([], 0, "Saved 'cordis'", ""),
98
+ CompletedProcess([], 0, "Connected", ""),
99
+ ]
100
+ with patch("cordis_bridge.setup._protocol_path", return_value=Path(self.tempdir.name) / "AGENTS.md"):
101
+ result = setup_hermes(Path(self.tempdir.name) / "hermes-state")
102
+ self.assertEqual(run.call_args_list[1].kwargs["input_text"], "y\n")
103
+ self.assertTrue(result["verified"])