continuum-mcp-server 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,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: continuum-mcp-server
3
+ Version: 0.1.1
4
+ Summary: MCP server exposing Continuum decision tools
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: continuum-sdk>=0.1.1
8
+ Requires-Dist: mcp>=1.0
@@ -0,0 +1,22 @@
1
+ # Continuum MCP Server
2
+
3
+ MCP server exposing Continuum decision tools: inspect, resolve, enforce, commit, supersede.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install continuum-mcp-server
9
+ pip install "mcp>=1.0"
10
+ ```
11
+
12
+ ## Run
13
+
14
+ ```bash
15
+ continuum-mcp serve
16
+ ```
17
+
18
+ Point at a repo-local store:
19
+
20
+ ```bash
21
+ CONTINUUM_STORE="/path/to/.continuum" continuum-mcp serve
22
+ ```
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env python3
2
+ """Manual MCP smoke test.
3
+
4
+ This script starts the Continuum MCP server over stdio, calls a few tools,
5
+ and verifies the repo-local store is consistent with the local SDK.
6
+
7
+ Prereqs:
8
+ pip install continuum-mcp-server continuum-sdk "mcp>=1.0"
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import os
15
+ import tempfile
16
+
17
+
18
+ async def _run() -> int:
19
+ try:
20
+ from mcp import ClientSession, StdioServerParameters
21
+ from mcp.client.stdio import stdio_client
22
+ except Exception as exc: # pragma: no cover
23
+ print("ERROR: Missing MCP client deps. Install with: pip install 'mcp>=1.0'")
24
+ print(f"Details: {exc}")
25
+ return 1
26
+
27
+ from continuum.client import ContinuumClient
28
+
29
+ with tempfile.TemporaryDirectory() as td:
30
+ store_dir = os.path.join(td, ".continuum")
31
+
32
+ server_params = StdioServerParameters(
33
+ command="python3",
34
+ args=["-m", "continuum_mcp.server", "serve"],
35
+ env={**os.environ, "CONTINUUM_STORE": store_dir},
36
+ )
37
+
38
+ async with stdio_client(server_params) as (read_stream, write_stream):
39
+ async with ClientSession(read_stream, write_stream) as session:
40
+ await session.initialize()
41
+
42
+ scope = "repo:smoke"
43
+
44
+ commit_res = await session.call_tool(
45
+ "continuum_commit",
46
+ {
47
+ "title": "Reject full rewrites",
48
+ "scope": scope,
49
+ "decision_type": "rejection",
50
+ "options": [
51
+ {"title": "Incremental refactor", "selected": True},
52
+ {
53
+ "title": "Full rewrite",
54
+ "selected": False,
55
+ "rejected_reason": "Too risky",
56
+ },
57
+ ],
58
+ "rationale": "Prefer incremental refactors.",
59
+ "activate": True,
60
+ },
61
+ )
62
+ print("commit:", commit_res)
63
+
64
+ inspect_res = await session.call_tool(
65
+ "continuum_inspect",
66
+ {"scope": scope},
67
+ )
68
+ print("inspect(scope):", inspect_res)
69
+
70
+ resolve_res = await session.call_tool(
71
+ "continuum_resolve",
72
+ {
73
+ "prompt": "Reject full rewrites",
74
+ "scope": scope,
75
+ },
76
+ )
77
+ print("resolve:", resolve_res)
78
+
79
+ # Verify local SDK sees the same store.
80
+ client = ContinuumClient(storage_dir=store_dir)
81
+ binding = client.inspect(scope)
82
+ assert any(d["title"] == "Reject full rewrites" for d in binding)
83
+
84
+ print("SMOKE TEST PASSED")
85
+ return 0
86
+
87
+
88
+ def main() -> None:
89
+ raise SystemExit(asyncio.run(_run()))
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
94
+
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "continuum-mcp-server"
7
+ version = "0.1.1"
8
+ description = "MCP server exposing Continuum decision tools"
9
+ requires-python = ">=3.10"
10
+ license = {text = "Apache-2.0"}
11
+ dependencies = [
12
+ "continuum-sdk>=0.1.1",
13
+ "mcp>=1.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ continuum-mcp = "continuum_mcp.server:main"
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,389 @@
1
+ """Continuum MCP Server.
2
+
3
+ Exposes Continuum decision operations as MCP tools:
4
+ - inspect: binding set by scope OR a decision by ID
5
+ - resolve: ambiguity gate against prior decisions
6
+ - enforce: enforcement verdict for a proposed action
7
+ - commit: persist a new decision (optionally activate)
8
+ - supersede: replace an existing decision with a new active one
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import json
15
+ import sys
16
+ from typing import Any
17
+
18
+ # SDK
19
+ from continuum.client import ContinuumClient
20
+ from continuum.exceptions import ContinuumError
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # MCP SDK imports — gracefully degrade if not installed
24
+ # ---------------------------------------------------------------------------
25
+ try:
26
+ from mcp.server import Server
27
+ from mcp.types import Tool, TextContent
28
+
29
+ _HAS_MCP = True
30
+ except ImportError:
31
+ _HAS_MCP = False
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Tool definitions
35
+ # ---------------------------------------------------------------------------
36
+
37
+ TOOLS: list[dict[str, Any]] = [
38
+ {
39
+ "name": "continuum_inspect",
40
+ "description": (
41
+ "Inspect Continuum decisions. Provide either `decision_id` to fetch a single decision, "
42
+ "or `scope` to fetch the active binding set for that scope."
43
+ ),
44
+ "inputSchema": {
45
+ "type": "object",
46
+ "properties": {
47
+ "decision_id": {
48
+ "type": "string",
49
+ "description": "The unique decision identifier.",
50
+ },
51
+ "scope": {
52
+ "type": "string",
53
+ "description": "Scope to inspect (returns active binding set).",
54
+ },
55
+ },
56
+ "anyOf": [{"required": ["decision_id"]}, {"required": ["scope"]}],
57
+ },
58
+ },
59
+ {
60
+ "name": "continuum_resolve",
61
+ "description": (
62
+ "Check whether a prior decision already covers the given prompt and scope. "
63
+ "Returns resolved context or a needs_clarification signal."
64
+ ),
65
+ "inputSchema": {
66
+ "type": "object",
67
+ "properties": {
68
+ "prompt": {
69
+ "type": "string",
70
+ "description": "The agent prompt to resolve against prior decisions.",
71
+ },
72
+ "scope": {
73
+ "type": "string",
74
+ "description": "Hierarchical scope identifier (e.g. repo:acme/backend).",
75
+ },
76
+ "candidates": {
77
+ "type": "array",
78
+ "description": "Optional candidate options (id, title) for disambiguation.",
79
+ "items": {"type": "object"},
80
+ },
81
+ },
82
+ "required": ["prompt", "scope"],
83
+ },
84
+ },
85
+ {
86
+ "name": "continuum_enforce",
87
+ "description": (
88
+ "Evaluate enforcement rules for a proposed action in a scope. "
89
+ "Returns a verdict: allow, confirm, or block (deterministic)."
90
+ ),
91
+ "inputSchema": {
92
+ "type": "object",
93
+ "properties": {
94
+ "scope": {
95
+ "type": "string",
96
+ "description": "Scope to evaluate enforcement within.",
97
+ },
98
+ "action": {
99
+ "type": "object",
100
+ "description": "Proposed action (type, description, metadata).",
101
+ },
102
+ },
103
+ "required": ["scope", "action"],
104
+ },
105
+ },
106
+ {
107
+ "name": "continuum_commit",
108
+ "description": "Persist a new decision with title, scope, options, and rationale.",
109
+ "inputSchema": {
110
+ "type": "object",
111
+ "properties": {
112
+ "title": {
113
+ "type": "string",
114
+ "description": "Short title describing the decision.",
115
+ },
116
+ "scope": {
117
+ "type": "string",
118
+ "description": "Hierarchical scope identifier.",
119
+ },
120
+ "decision_type": {
121
+ "type": "string",
122
+ "description": "Type of decision (e.g. rejection, selection).",
123
+ },
124
+ "options": {
125
+ "type": "array",
126
+ "description": "List of options considered.",
127
+ "items": {"type": "object"},
128
+ },
129
+ "rationale": {
130
+ "type": "string",
131
+ "description": "Why this decision was made.",
132
+ },
133
+ "stakeholders": {
134
+ "type": "array",
135
+ "description": "Optional list of stakeholders.",
136
+ "items": {"type": "string"},
137
+ },
138
+ "metadata": {
139
+ "type": "object",
140
+ "description": "Optional decision metadata.",
141
+ },
142
+ "override_policy": {
143
+ "type": "string",
144
+ "description": "Override policy: invalid_by_default | warn | allow",
145
+ },
146
+ "precedence": {
147
+ "type": "integer",
148
+ "description": "Optional precedence for conflict resolution.",
149
+ },
150
+ "supersedes": {
151
+ "type": "string",
152
+ "description": "Optional decision id this decision supersedes.",
153
+ },
154
+ "activate": {
155
+ "type": "boolean",
156
+ "description": "If true, transition the decision to active immediately.",
157
+ "default": False,
158
+ },
159
+ },
160
+ "required": ["title", "scope", "decision_type", "rationale"],
161
+ },
162
+ },
163
+ {
164
+ "name": "continuum_supersede",
165
+ "description": "Supersede an existing decision by committing a replacement and activating it.",
166
+ "inputSchema": {
167
+ "type": "object",
168
+ "properties": {
169
+ "old_id": {
170
+ "type": "string",
171
+ "description": "ID of the decision being replaced.",
172
+ },
173
+ "new_title": {
174
+ "type": "string",
175
+ "description": "Title for the replacement decision.",
176
+ },
177
+ "rationale": {
178
+ "type": "string",
179
+ "description": "Rationale for the replacement decision.",
180
+ },
181
+ "options": {
182
+ "type": "array",
183
+ "description": "Optional list of options considered.",
184
+ "items": {"type": "object"},
185
+ },
186
+ "stakeholders": {
187
+ "type": "array",
188
+ "description": "Optional list of stakeholders.",
189
+ "items": {"type": "string"},
190
+ },
191
+ "metadata": {
192
+ "type": "object",
193
+ "description": "Optional decision metadata.",
194
+ },
195
+ "override_policy": {
196
+ "type": "string",
197
+ "description": "Override policy: invalid_by_default | warn | allow",
198
+ },
199
+ "precedence": {
200
+ "type": "integer",
201
+ "description": "Optional precedence for conflict resolution.",
202
+ },
203
+ },
204
+ "required": ["old_id", "new_title"],
205
+ },
206
+ },
207
+ ]
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Tool handlers
211
+ # ---------------------------------------------------------------------------
212
+
213
+
214
+ def _client() -> ContinuumClient:
215
+ storage_dir = os.environ.get("CONTINUUM_STORE")
216
+ return ContinuumClient(storage_dir=storage_dir) if storage_dir else ContinuumClient()
217
+
218
+
219
+ def _ok(payload: Any) -> str:
220
+ return json.dumps({"status": "ok", "result": payload}, default=str)
221
+
222
+
223
+ def _err(message: str) -> str:
224
+ return json.dumps({"status": "error", "error": message})
225
+
226
+
227
+ def _handle_inspect(arguments: dict[str, Any]) -> str:
228
+ """Inspect by decision_id (single record) OR by scope (binding set)."""
229
+ try:
230
+ client = _client()
231
+ if "decision_id" in arguments and arguments["decision_id"]:
232
+ dec = client.get(str(arguments["decision_id"]))
233
+ return _ok(dec.model_dump(mode="json"))
234
+ if "scope" in arguments and arguments["scope"]:
235
+ binding = client.inspect(str(arguments["scope"]))
236
+ return _ok(binding)
237
+ return _err("Provide either 'decision_id' or 'scope'.")
238
+ except ContinuumError as exc:
239
+ return _err(str(exc))
240
+
241
+
242
+ def _handle_resolve(arguments: dict[str, Any]) -> str:
243
+ """Resolve a prompt against prior decisions."""
244
+ try:
245
+ client = _client()
246
+ prompt = str(arguments.get("prompt", ""))
247
+ scope = str(arguments.get("scope", ""))
248
+ candidates = arguments.get("candidates")
249
+ result = client.resolve(query=prompt, scope=scope, candidates=candidates)
250
+ return _ok(result)
251
+ except ContinuumError as exc:
252
+ return _err(str(exc))
253
+
254
+
255
+ def _handle_enforce(arguments: dict[str, Any]) -> str:
256
+ """Enforce rules for a proposed action within a scope."""
257
+ try:
258
+ client = _client()
259
+ scope = str(arguments.get("scope", ""))
260
+ action = arguments.get("action") or {}
261
+ result = client.enforce(action=action, scope=scope)
262
+ return _ok(result)
263
+ except ContinuumError as exc:
264
+ return _err(str(exc))
265
+
266
+
267
+ def _handle_commit(arguments: dict[str, Any]) -> str:
268
+ """Commit a new decision."""
269
+ try:
270
+ client = _client()
271
+ dec = client.commit(
272
+ title=str(arguments["title"]),
273
+ scope=str(arguments["scope"]),
274
+ decision_type=str(arguments["decision_type"]),
275
+ options=arguments.get("options"),
276
+ rationale=arguments.get("rationale"),
277
+ stakeholders=arguments.get("stakeholders"),
278
+ metadata=arguments.get("metadata"),
279
+ override_policy=arguments.get("override_policy"),
280
+ precedence=arguments.get("precedence"),
281
+ supersedes=arguments.get("supersedes"),
282
+ )
283
+ if arguments.get("activate"):
284
+ dec = client.update_status(dec.id, "active")
285
+ return _ok(dec.model_dump(mode="json"))
286
+ except (KeyError, TypeError) as exc:
287
+ return _err(f"Invalid arguments: {exc}")
288
+ except ContinuumError as exc:
289
+ return _err(str(exc))
290
+
291
+
292
+ def _handle_supersede(arguments: dict[str, Any]) -> str:
293
+ """Supersede an existing decision."""
294
+ try:
295
+ client = _client()
296
+ dec = client.supersede(
297
+ old_id=str(arguments["old_id"]),
298
+ new_title=str(arguments["new_title"]),
299
+ rationale=arguments.get("rationale"),
300
+ options=arguments.get("options"),
301
+ stakeholders=arguments.get("stakeholders"),
302
+ metadata=arguments.get("metadata"),
303
+ override_policy=arguments.get("override_policy"),
304
+ precedence=arguments.get("precedence"),
305
+ )
306
+ return _ok(dec.model_dump(mode="json"))
307
+ except (KeyError, TypeError) as exc:
308
+ return _err(f"Invalid arguments: {exc}")
309
+ except ContinuumError as exc:
310
+ return _err(str(exc))
311
+
312
+
313
+ _HANDLERS: dict[str, Any] = {
314
+ "continuum_inspect": _handle_inspect,
315
+ "continuum_resolve": _handle_resolve,
316
+ "continuum_enforce": _handle_enforce,
317
+ "continuum_commit": _handle_commit,
318
+ "continuum_supersede": _handle_supersede,
319
+ }
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # Server setup
323
+ # ---------------------------------------------------------------------------
324
+
325
+
326
+ def main() -> None:
327
+ """Entry point for the `continuum-mcp` console script.
328
+
329
+ Usage:
330
+ continuum-mcp serve
331
+
332
+ Environment:
333
+ CONTINUUM_STORE=/path/to/.continuum
334
+ """
335
+ # Minimal CLI wrapper (avoid extra deps).
336
+ cmd = sys.argv[1] if len(sys.argv) > 1 else "serve"
337
+ if cmd in ("-h", "--help", "help"):
338
+ print(
339
+ "Continuum MCP Server\n\n"
340
+ "Usage:\n"
341
+ " continuum-mcp serve\n\n"
342
+ "Environment:\n"
343
+ " CONTINUUM_STORE Path to repo-local .continuum directory\n",
344
+ file=sys.stdout,
345
+ )
346
+ return
347
+ if cmd != "serve":
348
+ print(f"Unknown command: {cmd}\nRun: continuum-mcp --help", file=sys.stderr)
349
+ sys.exit(2)
350
+
351
+ serve()
352
+
353
+
354
+ def serve() -> None:
355
+ """Start the Continuum MCP server (stdio transport)."""
356
+ if not _HAS_MCP:
357
+ print(
358
+ "ERROR: The 'mcp' package is not installed. "
359
+ "Install it with: pip install 'mcp>=1.0'",
360
+ file=sys.stderr,
361
+ )
362
+ sys.exit(1)
363
+
364
+ server = Server("continuum-mcp")
365
+
366
+ @server.list_tools()
367
+ async def list_tools() -> list[Tool]:
368
+ return [Tool(**t) for t in TOOLS]
369
+
370
+ @server.call_tool()
371
+ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
372
+ handler = _HANDLERS.get(name)
373
+ if handler is None:
374
+ return [TextContent(type="text", text=f"Unknown tool: {name}")]
375
+ result = handler(arguments)
376
+ return [TextContent(type="text", text=result)]
377
+
378
+ import asyncio
379
+ from mcp.server.stdio import stdio_server
380
+
381
+ async def _run() -> None:
382
+ async with stdio_server() as (read_stream, write_stream):
383
+ await server.run(read_stream, write_stream)
384
+
385
+ asyncio.run(_run())
386
+
387
+
388
+ if __name__ == "__main__":
389
+ main()
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: continuum-mcp-server
3
+ Version: 0.1.1
4
+ Summary: MCP server exposing Continuum decision tools
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: continuum-sdk>=0.1.1
8
+ Requires-Dist: mcp>=1.0
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ examples/smoke_test.py
4
+ src/continuum_mcp/__init__.py
5
+ src/continuum_mcp/server.py
6
+ src/continuum_mcp_server.egg-info/PKG-INFO
7
+ src/continuum_mcp_server.egg-info/SOURCES.txt
8
+ src/continuum_mcp_server.egg-info/dependency_links.txt
9
+ src/continuum_mcp_server.egg-info/entry_points.txt
10
+ src/continuum_mcp_server.egg-info/requires.txt
11
+ src/continuum_mcp_server.egg-info/top_level.txt
12
+ tests/test_mcp_e2e.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ continuum-mcp = continuum_mcp.server:main
@@ -0,0 +1,2 @@
1
+ continuum-sdk>=0.1.1
2
+ mcp>=1.0
@@ -0,0 +1,305 @@
1
+ """End-to-end tests for the Continuum MCP server.
2
+
3
+ These tests exercise the MCP tool handlers directly (no stdio transport)
4
+ against a temporary store directory, covering the full lifecycle:
5
+ commit -> inspect -> resolve -> enforce -> supersede
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+
13
+ import pytest
14
+
15
+ # Import handlers directly for fast, reliable testing without MCP transport.
16
+ from continuum_mcp.server import (
17
+ _handle_commit,
18
+ _handle_enforce,
19
+ _handle_inspect,
20
+ _handle_resolve,
21
+ _handle_supersede,
22
+ )
23
+
24
+
25
+ @pytest.fixture(autouse=True)
26
+ def _use_temp_store(tmp_path, monkeypatch):
27
+ """Point the MCP server at a temp store."""
28
+ store = str(tmp_path / ".continuum")
29
+ monkeypatch.setenv("CONTINUUM_STORE", store)
30
+
31
+
32
+ def _parse(result: str) -> dict:
33
+ """Parse a handler JSON response."""
34
+ data = json.loads(result)
35
+ assert data["status"] == "ok", f"Handler error: {data.get('error')}"
36
+ return data["result"]
37
+
38
+
39
+ def _parse_err(result: str) -> str:
40
+ """Parse a handler error response."""
41
+ data = json.loads(result)
42
+ assert data["status"] == "error"
43
+ return data["error"]
44
+
45
+
46
+ # ------------------------------------------------------------------
47
+ # commit
48
+ # ------------------------------------------------------------------
49
+
50
+
51
+ class TestCommit:
52
+ def test_basic_commit(self):
53
+ result = _parse(_handle_commit({
54
+ "title": "Test decision",
55
+ "scope": "repo:test",
56
+ "decision_type": "rejection",
57
+ "rationale": "For testing.",
58
+ }))
59
+ assert result["title"] == "Test decision"
60
+ assert result["id"].startswith("dec_")
61
+ assert result["status"] == "draft"
62
+
63
+ def test_commit_with_activate(self):
64
+ result = _parse(_handle_commit({
65
+ "title": "Active decision",
66
+ "scope": "repo:test",
67
+ "decision_type": "preference",
68
+ "rationale": "Immediately active.",
69
+ "activate": True,
70
+ }))
71
+ assert result["status"] == "active"
72
+
73
+ def test_commit_with_options(self):
74
+ result = _parse(_handle_commit({
75
+ "title": "With options",
76
+ "scope": "repo:test",
77
+ "decision_type": "rejection",
78
+ "rationale": "Testing options.",
79
+ "options": [
80
+ {"title": "A", "selected": True},
81
+ {"title": "B", "selected": False, "rejected_reason": "Not chosen"},
82
+ ],
83
+ }))
84
+ assert len(result["options_considered"]) == 2
85
+
86
+ def test_commit_with_full_params(self):
87
+ result = _parse(_handle_commit({
88
+ "title": "Full params",
89
+ "scope": "repo:test",
90
+ "decision_type": "interpretation",
91
+ "rationale": "Testing all params.",
92
+ "stakeholders": ["alice", "bob"],
93
+ "metadata": {"team": "platform"},
94
+ "override_policy": "warn",
95
+ "precedence": 10,
96
+ }))
97
+ assert result["stakeholders"] == ["alice", "bob"]
98
+ assert result["metadata"]["team"] == "platform"
99
+
100
+ def test_commit_missing_required(self):
101
+ raw = _handle_commit({"title": "Missing scope"})
102
+ data = json.loads(raw)
103
+ assert data["status"] == "error"
104
+
105
+
106
+ # ------------------------------------------------------------------
107
+ # inspect
108
+ # ------------------------------------------------------------------
109
+
110
+
111
+ class TestInspect:
112
+ def test_inspect_by_id(self):
113
+ dec = _parse(_handle_commit({
114
+ "title": "Inspectable",
115
+ "scope": "repo:test",
116
+ "decision_type": "rejection",
117
+ "rationale": "For inspect test.",
118
+ }))
119
+ result = _parse(_handle_inspect({"decision_id": dec["id"]}))
120
+ assert result["id"] == dec["id"]
121
+
122
+ def test_inspect_by_scope(self):
123
+ _parse(_handle_commit({
124
+ "title": "Scope inspect",
125
+ "scope": "repo:inspect-scope",
126
+ "decision_type": "rejection",
127
+ "rationale": "For scope test.",
128
+ "activate": True,
129
+ }))
130
+ result = _parse(_handle_inspect({"scope": "repo:inspect-scope"}))
131
+ assert isinstance(result, list)
132
+ assert len(result) >= 1
133
+
134
+ def test_inspect_no_args(self):
135
+ err = _parse_err(_handle_inspect({}))
136
+ assert "Provide either" in err
137
+
138
+ def test_inspect_nonexistent_id(self):
139
+ err = _parse_err(_handle_inspect({"decision_id": "dec_nonexistent"}))
140
+ assert "not found" in err.lower()
141
+
142
+
143
+ # ------------------------------------------------------------------
144
+ # resolve
145
+ # ------------------------------------------------------------------
146
+
147
+
148
+ class TestResolve:
149
+ def test_resolve_no_decisions(self):
150
+ result = _parse(_handle_resolve({
151
+ "prompt": "something new",
152
+ "scope": "repo:test",
153
+ }))
154
+ assert "status" in result
155
+
156
+ def test_resolve_with_matching_decision(self):
157
+ _parse(_handle_commit({
158
+ "title": "Reject full rewrites",
159
+ "scope": "repo:resolve-test",
160
+ "decision_type": "rejection",
161
+ "rationale": "Too risky.",
162
+ "activate": True,
163
+ }))
164
+ result = _parse(_handle_resolve({
165
+ "prompt": "Reject full rewrites",
166
+ "scope": "repo:resolve-test",
167
+ }))
168
+ assert result["status"] == "resolved"
169
+
170
+ def test_resolve_with_candidates(self):
171
+ result = _parse(_handle_resolve({
172
+ "prompt": "Pick approach",
173
+ "scope": "repo:test",
174
+ "candidates": [
175
+ {"id": "a", "title": "Approach A"},
176
+ {"id": "b", "title": "Approach B"},
177
+ ],
178
+ }))
179
+ assert "status" in result
180
+
181
+
182
+ # ------------------------------------------------------------------
183
+ # enforce
184
+ # ------------------------------------------------------------------
185
+
186
+
187
+ class TestEnforce:
188
+ def test_enforce_no_decisions(self):
189
+ result = _parse(_handle_enforce({
190
+ "scope": "repo:test",
191
+ "action": {"type": "code_change", "description": "minor fix"},
192
+ }))
193
+ assert "verdict" in result
194
+
195
+ def test_enforce_with_rejection(self):
196
+ _parse(_handle_commit({
197
+ "title": "Reject full rewrites",
198
+ "scope": "repo:enforce-test",
199
+ "decision_type": "rejection",
200
+ "rationale": "Too risky.",
201
+ "options": [
202
+ {"title": "Incremental refactor", "selected": True},
203
+ {"title": "Full rewrite", "selected": False, "rejected_reason": "Too risky"},
204
+ ],
205
+ "activate": True,
206
+ }))
207
+ result = _parse(_handle_enforce({
208
+ "scope": "repo:enforce-test",
209
+ "action": {"type": "code_change", "description": "Do a full rewrite of auth"},
210
+ }))
211
+ assert "verdict" in result
212
+
213
+
214
+ # ------------------------------------------------------------------
215
+ # supersede
216
+ # ------------------------------------------------------------------
217
+
218
+
219
+ class TestSupersede:
220
+ def test_supersede_lifecycle(self):
221
+ # Commit and activate
222
+ dec = _parse(_handle_commit({
223
+ "title": "V1 decision",
224
+ "scope": "repo:supersede-test",
225
+ "decision_type": "rejection",
226
+ "rationale": "Original.",
227
+ "activate": True,
228
+ }))
229
+ assert dec["status"] == "active"
230
+
231
+ # Supersede
232
+ new_dec = _parse(_handle_supersede({
233
+ "old_id": dec["id"],
234
+ "new_title": "V2 decision",
235
+ "rationale": "Updated approach.",
236
+ }))
237
+ assert new_dec["title"] == "V2 decision"
238
+ assert new_dec["status"] == "active"
239
+
240
+ # Verify old is superseded
241
+ old = _parse(_handle_inspect({"decision_id": dec["id"]}))
242
+ assert old["status"] == "superseded"
243
+
244
+ def test_supersede_nonexistent(self):
245
+ err = _parse_err(_handle_supersede({
246
+ "old_id": "dec_nonexistent",
247
+ "new_title": "Won't work",
248
+ }))
249
+ assert "not found" in err.lower()
250
+
251
+
252
+ # ------------------------------------------------------------------
253
+ # Full lifecycle (integration)
254
+ # ------------------------------------------------------------------
255
+
256
+
257
+ class TestFullLifecycle:
258
+ def test_commit_inspect_resolve_enforce_supersede(self):
259
+ scope = "repo:lifecycle"
260
+
261
+ # 1. Commit
262
+ dec = _parse(_handle_commit({
263
+ "title": "Reject full rewrites",
264
+ "scope": scope,
265
+ "decision_type": "rejection",
266
+ "rationale": "Too risky.",
267
+ "options": [
268
+ {"title": "Incremental refactor", "selected": True},
269
+ {"title": "Full rewrite", "selected": False, "rejected_reason": "Too risky"},
270
+ ],
271
+ "activate": True,
272
+ }))
273
+ dec_id = dec["id"]
274
+
275
+ # 2. Inspect by scope
276
+ binding = _parse(_handle_inspect({"scope": scope}))
277
+ assert any(d["id"] == dec_id for d in binding)
278
+
279
+ # 3. Resolve
280
+ resolved = _parse(_handle_resolve({
281
+ "prompt": "Reject full rewrites",
282
+ "scope": scope,
283
+ }))
284
+ assert resolved["status"] == "resolved"
285
+
286
+ # 4. Enforce
287
+ enforcement = _parse(_handle_enforce({
288
+ "scope": scope,
289
+ "action": {"type": "code_change", "description": "full rewrite"},
290
+ }))
291
+ assert "verdict" in enforcement
292
+
293
+ # 5. Supersede
294
+ new_dec = _parse(_handle_supersede({
295
+ "old_id": dec_id,
296
+ "new_title": "V2: Allow rewrites for tests only",
297
+ "rationale": "Relaxed policy for test modules.",
298
+ }))
299
+ assert new_dec["status"] == "active"
300
+
301
+ # 6. Verify final state
302
+ final_binding = _parse(_handle_inspect({"scope": scope}))
303
+ active_ids = [d["id"] for d in final_binding]
304
+ assert new_dec["id"] in active_ids
305
+ assert dec_id not in active_ids # Old decision should be superseded